For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add granular fluent methods to control which shell operator groups (pipes, redirection, chaining, expansion) are allowed, plus a global toggle allowAllShellOperators().
Architecture: The CommandSanitizer already has $blockedCharacters and $injectionPatterns arrays. We add 4 boolean flags (one per operator group) that filter out the relevant chars/patterns when enabled. The flags flow from Schema fluent API -> Livewire mount params -> getSanitizer() construction. The global allowAllShellOperators() sets all 4 flags at once.
Tech Stack: PHP 8.2, Laravel, Livewire 4, Filament 5, Pest 3
Step 1: Create and checkout feature branch
Run: git checkout -b feature/shell-operator-controls
Expected: Switched to a new branch 'feature/shell-operator-controls'
Files:
src/Security/CommandSanitizer.phptests/Unit/Security/CommandSanitizerTest.phpStep 1: Write failing tests for operator group flags
Add these tests at the end of tests/Unit/Security/CommandSanitizerTest.php, before the closing });:
describe('shell operator controls', function () {
describe('allowPipes', function () {
it('blocks pipes by default', function () {
$sanitizer = new CommandSanitizer();
expect($sanitizer->isSafe('ls | grep foo'))->toBeFalse();
});
it('allows pipes when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowPipes();
expect($sanitizer->isSafe('ls | grep foo'))->toBeTrue();
});
it('still blocks other operators when only pipes allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowPipes();
expect($sanitizer->isSafe('ls; pwd'))->toBeFalse();
expect($sanitizer->isSafe('echo $HOME'))->toBeFalse();
expect($sanitizer->isSafe('echo > file'))->toBeFalse();
});
});
describe('allowRedirection', function () {
it('blocks redirection by default', function () {
$sanitizer = new CommandSanitizer();
expect($sanitizer->isSafe('echo test > file.txt'))->toBeFalse();
});
it('allows output redirection when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowRedirection();
expect($sanitizer->isSafe('echo test > file.txt'))->toBeTrue();
});
it('allows input redirection when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowRedirection();
expect($sanitizer->isSafe('cat < input.txt'))->toBeTrue();
});
it('allows append redirection when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowRedirection();
expect($sanitizer->isSafe('echo test >> file.txt'))->toBeTrue();
});
it('allows here-doc when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowRedirection();
expect($sanitizer->isSafe('cat << EOF'))->toBeTrue();
});
it('still blocks other operators when only redirection allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowRedirection();
expect($sanitizer->isSafe('ls | grep foo'))->toBeFalse();
expect($sanitizer->isSafe('ls; pwd'))->toBeFalse();
});
});
describe('allowChaining', function () {
it('blocks chaining by default', function () {
$sanitizer = new CommandSanitizer();
expect($sanitizer->isSafe('ls; pwd'))->toBeFalse();
});
it('allows semicolon when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowChaining();
expect($sanitizer->isSafe('ls; pwd'))->toBeTrue();
});
it('allows && when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowChaining();
expect($sanitizer->isSafe('ls && pwd'))->toBeTrue();
});
it('allows || when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowChaining();
expect($sanitizer->isSafe('ls || pwd'))->toBeTrue();
});
it('allows & (background) when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowChaining();
expect($sanitizer->isSafe('sleep 10 &'))->toBeTrue();
});
it('still blocks other operators when only chaining allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowChaining();
expect($sanitizer->isSafe('ls | grep foo'))->toBeFalse();
expect($sanitizer->isSafe('echo $HOME'))->toBeFalse();
});
});
describe('allowExpansion', function () {
it('blocks expansion by default', function () {
$sanitizer = new CommandSanitizer();
expect($sanitizer->isSafe('echo $HOME'))->toBeFalse();
});
it('allows dollar sign when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowExpansion();
expect($sanitizer->isSafe('echo $HOME'))->toBeTrue();
});
it('allows backticks when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowExpansion();
expect($sanitizer->isSafe('echo `whoami`'))->toBeTrue();
});
it('allows $() command substitution when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowExpansion();
expect($sanitizer->isSafe('echo $(whoami)'))->toBeTrue();
});
it('allows ${} variable expansion when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowExpansion();
expect($sanitizer->isSafe('echo ${HOME}'))->toBeTrue();
});
it('still blocks other operators when only expansion allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowExpansion();
expect($sanitizer->isSafe('ls | grep foo'))->toBeFalse();
expect($sanitizer->isSafe('ls; pwd'))->toBeFalse();
});
});
describe('allowAllShellOperators', function () {
it('allows all operators when enabled', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowAllShellOperators();
expect($sanitizer->isSafe('ls | grep foo'))->toBeTrue();
expect($sanitizer->isSafe('echo test > file.txt'))->toBeTrue();
expect($sanitizer->isSafe('ls; pwd'))->toBeTrue();
expect($sanitizer->isSafe('echo $HOME'))->toBeTrue();
expect($sanitizer->isSafe('echo `whoami`'))->toBeTrue();
});
it('still blocks null bytes even when all operators allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowAllShellOperators();
expect($sanitizer->isSafe("echo hello\x00malicious"))->toBeFalse();
});
it('still blocks newlines even when all operators allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowAllShellOperators();
expect($sanitizer->isSafe("echo hello\nrm -rf /"))->toBeFalse();
});
it('still blocks carriage returns even when all operators allowed', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowAllShellOperators();
expect($sanitizer->isSafe("echo hello\rrm -rf /"))->toBeFalse();
});
});
describe('combining groups', function () {
it('allows pipes and redirection together', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowPipes();
$sanitizer->allowRedirection();
expect($sanitizer->isSafe('ls | sort > output.txt'))->toBeTrue();
expect($sanitizer->isSafe('ls; pwd'))->toBeFalse();
});
it('allows pipes and chaining together', function () {
$sanitizer = new CommandSanitizer();
$sanitizer->allowPipes();
$sanitizer->allowChaining();
expect($sanitizer->isSafe('ls | grep foo && echo done'))->toBeTrue();
expect($sanitizer->isSafe('echo $HOME'))->toBeFalse();
});
});
describe('constructor flags', function () {
it('accepts operator flags in constructor', function () {
$sanitizer = new CommandSanitizer(
allowPipes: true,
allowRedirection: true,
);
expect($sanitizer->isSafe('ls | sort > output.txt'))->toBeTrue();
expect($sanitizer->isSafe('ls; pwd'))->toBeFalse();
});
});
});
Step 2: Run tests to verify they fail
Run: cd /home/guerra/projects/web-terminal && ./vendor/bin/pest tests/Unit/Security/CommandSanitizerTest.php --filter="shell operator controls"
Expected: FAIL - methods allowPipes, allowRedirection, etc. do not exist
Step 3: Implement operator group flags in CommandSanitizer
In src/Security/CommandSanitizer.php:
$autoEscape (line 57):protected bool $allowPipes = false;
protected bool $allowRedirection = false;
protected bool $allowChaining = false;
protected bool $allowExpansion = false;
public function __construct(
array $blockedCharacters = [],
bool $allowPipes = false,
bool $allowRedirection = false,
bool $allowChaining = false,
bool $allowExpansion = false,
)
{
if (! empty($blockedCharacters)) {
$this->blockedCharacters = $blockedCharacters;
}
$this->allowPipes = $allowPipes;
$this->allowRedirection = $allowRedirection;
$this->allowChaining = $allowChaining;
$this->allowExpansion = $allowExpansion;
}
setAutoEscape (line ~405):public function allowPipes(bool $allow = true): static
{
$this->allowPipes = $allow;
return $this;
}
public function allowRedirection(bool $allow = true): static
{
$this->allowRedirection = $allow;
return $this;
}
public function allowChaining(bool $allow = true): static
{
$this->allowChaining = $allow;
return $this;
}
public function allowExpansion(bool $allow = true): static
{
$this->allowExpansion = $allow;
return $this;
}
public function allowAllShellOperators(bool $allow = true): static
{
$this->allowPipes = $allow;
$this->allowRedirection = $allow;
$this->allowChaining = $allow;
$this->allowExpansion = $allow;
return $this;
}
checkBlockedCharacters method (line 135) to filter by group:protected function checkBlockedCharacters(string $command): void
{
foreach ($this->getEffectiveBlockedCharacters() as $char) {
if (str_contains($command, $char)) {
throw ValidationException::blockedCharacters($command, $char);
}
}
}
checkInjectionPatterns method (line 149) to filter by group:protected function checkInjectionPatterns(string $command): void
{
foreach ($this->getEffectiveInjectionPatterns() as $pattern) {
if (preg_match($pattern, $command)) {
throw ValidationException::injectionAttempt($command);
}
}
}
protected function getEffectiveBlockedCharacters(): array
{
$chars = $this->blockedCharacters;
if ($this->allowPipes) {
$chars = array_filter($chars, fn ($c) => $c !== '|');
}
if ($this->allowChaining) {
$chars = array_filter($chars, fn ($c) => ! in_array($c, [';', '&'], true));
}
if ($this->allowExpansion) {
$chars = array_filter($chars, fn ($c) => ! in_array($c, ['$', '`'], true));
}
return array_values($chars);
}
protected function getEffectiveInjectionPatterns(): array
{
$patterns = $this->injectionPatterns;
if ($this->allowRedirection) {
$patterns = array_filter($patterns, fn ($p) => ! in_array($p, [
'/\>\s*\>/', // >> redirect
'/\<\s*\</', // << here-doc
'/\>\s*\//', // > /path redirect
'/\<\s*\//', // < /path input redirect
], true));
}
if ($this->allowChaining) {
$patterns = array_filter($patterns, fn ($p) => ! in_array($p, [
'/\|\s*\|/', // || or
'/\&\s*\&/', // && and
], true));
}
if ($this->allowExpansion) {
$patterns = array_filter($patterns, fn ($p) => ! in_array($p, [
'/\$\(/', // $(command)
'/\$\{/', // ${variable}
'/\\\\x[0-9a-fA-F]{2}/', // Hex escape sequences
'/\\\\[0-7]{1,3}/', // Octal escape sequences
], true));
}
return array_values($patterns);
}
stripDangerous method (line 318) to use the effective lists:public function stripDangerous(string $input): string
{
$result = str_replace($this->getEffectiveBlockedCharacters(), '', $input);
foreach ($this->getEffectiveInjectionPatterns() as $pattern) {
$result = preg_replace($pattern, '', $result) ?? $result;
}
return $result;
}
Step 4: Run tests to verify they pass
Run: cd /home/guerra/projects/web-terminal && ./vendor/bin/pest tests/Unit/Security/CommandSanitizerTest.php
Expected: All tests PASS
Step 5: Commit
git add src/Security/CommandSanitizer.php tests/Unit/Security/CommandSanitizerTest.php
git commit -m "feat: add shell operator group controls to CommandSanitizer"
Files:
src/Livewire/WebTerminal.php:240-280 (properties), :413-437 (mount), :1310-1315 (getSanitizer)Step 1: Add properties to Livewire component
In src/Livewire/WebTerminal.php, add after line 264 ($allowAllCommands):
#[Locked]
public bool $allowPipes = false;
#[Locked]
public bool $allowRedirection = false;
#[Locked]
public bool $allowChaining = false;
#[Locked]
public bool $allowExpansion = false;
#[Locked]
public bool $allowAllShellOperators = false;
Step 2: Add mount parameters
In the mount() method signature (line 413), add after bool $allowAllCommands = false,:
bool $allowPipes = false,
bool $allowRedirection = false,
bool $allowChaining = false,
bool $allowExpansion = false,
bool $allowAllShellOperators = false,
In the mount body, add after line 474 ($this->allowAllCommands = $allowAllCommands;):
$this->allowPipes = $allowPipes || $allowAllShellOperators;
$this->allowRedirection = $allowRedirection || $allowAllShellOperators;
$this->allowChaining = $allowChaining || $allowAllShellOperators;
$this->allowExpansion = $allowExpansion || $allowAllShellOperators;
$this->allowAllShellOperators = $allowAllShellOperators;
Step 3: Update getSanitizer to pass flags
Replace the getSanitizer() method (line 1310):
protected function getSanitizer(): CommandSanitizer
{
$blockedChars = config('web-terminal.security.blocked_characters', []);
return new CommandSanitizer(
blockedCharacters: $blockedChars,
allowPipes: $this->allowPipes,
allowRedirection: $this->allowRedirection,
allowChaining: $this->allowChaining,
allowExpansion: $this->allowExpansion,
);
}
Step 4: Run existing tests
Run: cd /home/guerra/projects/web-terminal && ./vendor/bin/pest tests/Unit/Livewire/WebTerminalTest.php
Expected: All existing tests PASS
Step 5: Commit
git add src/Livewire/WebTerminal.php
git commit -m "feat: wire shell operator flags through Livewire mount"
Files:
src/Schemas/Components/WebTerminal.php:25-77 (properties), :96-131 (getComponentProperties), and add new methods after line 304 (allowAllCommands section)tests/Unit/Schemas/Components/WebTerminalTest.phpStep 1: Write failing tests
Add these tests at the end of tests/Unit/Schemas/Components/WebTerminalTest.php, before the closing (no closing — just add at end):
describe('allowPipes', function () {
it('does not allow pipes by default', function () {
$component = WebTerminal::make();
expect($component->getAllowPipes())->toBeFalse();
});
it('enables pipes', function () {
$component = WebTerminal::make()->allowPipes();
expect($component->getAllowPipes())->toBeTrue();
});
it('returns self for method chaining', function () {
$component = WebTerminal::make();
expect($component->allowPipes())->toBe($component);
});
});
describe('allowRedirection', function () {
it('does not allow redirection by default', function () {
$component = WebTerminal::make();
expect($component->getAllowRedirection())->toBeFalse();
});
it('enables redirection', function () {
$component = WebTerminal::make()->allowRedirection();
expect($component->getAllowRedirection())->toBeTrue();
});
it('returns self for method chaining', function () {
$component = WebTerminal::make();
expect($component->allowRedirection())->toBe($component);
});
});
describe('allowChaining', function () {
it('does not allow chaining by default', function () {
$component = WebTerminal::make();
expect($component->getAllowChaining())->toBeFalse();
});
it('enables chaining', function () {
$component = WebTerminal::make()->allowChaining();
expect($component->getAllowChaining())->toBeTrue();
});
it('returns self for method chaining', function () {
$component = WebTerminal::make();
expect($component->allowChaining())->toBe($component);
});
});
describe('allowExpansion', function () {
it('does not allow expansion by default', function () {
$component = WebTerminal::make();
expect($component->getAllowExpansion())->toBeFalse();
});
it('enables expansion', function () {
$component = WebTerminal::make()->allowExpansion();
expect($component->getAllowExpansion())->toBeTrue();
});
it('returns self for method chaining', function () {
$component = WebTerminal::make();
expect($component->allowExpansion())->toBe($component);
});
});
describe('allowAllShellOperators', function () {
it('does not allow all shell operators by default', function () {
$component = WebTerminal::make();
expect($component->getAllowAllShellOperators())->toBeFalse();
});
it('enables all shell operators', function () {
$component = WebTerminal::make()->allowAllShellOperators();
expect($component->getAllowAllShellOperators())->toBeTrue();
expect($component->getAllowPipes())->toBeTrue();
expect($component->getAllowRedirection())->toBeTrue();
expect($component->getAllowChaining())->toBeTrue();
expect($component->getAllowExpansion())->toBeTrue();
});
it('can disable all shell operators', function () {
$component = WebTerminal::make()
->allowAllShellOperators()
->allowAllShellOperators(false);
expect($component->getAllowAllShellOperators())->toBeFalse();
});
it('returns self for method chaining', function () {
$component = WebTerminal::make();
expect($component->allowAllShellOperators())->toBe($component);
});
});
Step 2: Run tests to verify they fail
Run: cd /home/guerra/projects/web-terminal && ./vendor/bin/pest tests/Unit/Schemas/Components/WebTerminalTest.php --filter="allowPipes|allowRedirection|allowChaining|allowExpansion|allowAllShellOperators"
Expected: FAIL - methods do not exist
Step 3: Implement fluent methods
In src/Schemas/Components/WebTerminal.php:
protected bool|Closure $allowAll = false;):protected bool|Closure $allowPipes = false;
protected bool|Closure $allowRedirection = false;
protected bool|Closure $allowChaining = false;
protected bool|Closure $allowExpansion = false;
protected bool|Closure $allowAllShellOperators = false;
getComponentProperties() array (after line 110 'allowAllCommands'):'allowPipes' => $this->getAllowPipes(),
'allowRedirection' => $this->getAllowRedirection(),
'allowChaining' => $this->getAllowChaining(),
'allowExpansion' => $this->getAllowExpansion(),
'allowAllShellOperators' => $this->getAllowAllShellOperators(),
getAllowAll() method (after line 304, before the Environment Configuration section):/**
* Allow pipe operators (|) in commands.
*
* Enables piping output between commands (e.g., `ls | grep foo`).
* Risk: Low - pipes pass data between processes.
*/
public function allowPipes(bool|Closure $allow = true): static
{
$this->allowPipes = $allow;
return $this;
}
public function getAllowPipes(): bool
{
return $this->evaluate($this->allowPipes);
}
/**
* Allow redirection operators (>, <, >>, <<) in commands.
*
* Enables file redirection (e.g., `echo test > file.txt`).
* Risk: Medium - can overwrite or read files.
*/
p...
How can I help you explore Laravel packages today?