diff --git a/.env.example b/.env.example index 256527e3c..0ddacb9c3 100644 --- a/.env.example +++ b/.env.example @@ -75,4 +75,10 @@ SUPPORT_AI_LIVE_PROMOTION=pr_only SUPPORT_AI_LIVE_BRANCH=master # Token-gated; promotion PR is skipped if absent. owner/repo form. SUPPORT_GITHUB_REPO=codeeu/codeweek -SUPPORT_GITHUB_TOKEN= \ No newline at end of file +SUPPORT_GITHUB_TOKEN= + +# Phase 2 — AI artisan changes (allowlist-first, dry-run + APPROVE) +SUPPORT_AI_ARTISAN_ENABLED=false +SUPPORT_AI_ARTISAN_ALLOW_RAW=true +SUPPORT_AI_ARTISAN_TIMEOUT=120 +SUPPORT_AI_ARTISAN_OUTPUT_LIMIT=8000 \ No newline at end of file diff --git a/app/Console/Commands/Support/AiSetupCheckCommand.php b/app/Console/Commands/Support/AiSetupCheckCommand.php index e57d271fd..dc0626a63 100644 --- a/app/Console/Commands/Support/AiSetupCheckCommand.php +++ b/app/Console/Commands/Support/AiSetupCheckCommand.php @@ -21,6 +21,8 @@ public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService $checks['ai_enabled'] = (bool) config('support_ai.enabled'); $checks['triage_enabled'] = (bool) config('support_ai.triage.enabled'); $checks['code_change_enabled'] = (bool) config('support_ai.code_change.enabled'); + $checks['artisan_enabled'] = (bool) config('support_ai.artisan.enabled'); + $checks['artisan_allow_raw'] = (bool) config('support_ai.artisan.allow_raw_fallback', true); $checks['gmail_dry_run'] = (bool) config('support_gmail.dry_run', true); $apiKey = trim((string) config('support_ai.cursor_api_key', '')); @@ -68,7 +70,15 @@ public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService $warnings[] = 'Missing support_cases columns — run: php artisan migrate.'; } - $checks['code_change_in_allowed_actions'] = in_array('code_change', (array) config('support_gmail.allowed_write_actions', []), true); + $allowedActions = (array) config('support_gmail.allowed_write_actions', []); + $checks['code_change_in_allowed_actions'] = in_array('code_change', $allowedActions, true); + $checks['artisan_command_in_allowed_actions'] = in_array('artisan_command', $allowedActions, true); + if ($checks['artisan_enabled'] && !$checks['artisan_command_in_allowed_actions']) { + $warnings[] = "artisan_command missing from support_gmail.allowed_write_actions — approvals can't execute it."; + } + if ($checks['artisan_enabled'] && $checks['gmail_dry_run'] === false) { + $warnings[] = 'Artisan actions enabled with SUPPORT_GMAIL_DRY_RUN=false — commands run live after APPROVE.'; + } // Dev -> Live promotion. $checks['live_promotion'] = (string) config('support_ai.live_promotion', 'pr_only'); diff --git a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php index e002e4fba..4ae253471 100644 --- a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php +++ b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php @@ -4,6 +4,7 @@ use App\Models\Support\SupportApproval; use App\Models\Support\SupportCase; +use App\Services\Support\Artisan\ArtisanCommandRunner; use App\Services\Support\Cursor\CursorAgentService; use App\Services\Support\SupportActionLogger; use App\Services\Support\SupportApprovalEmailService; @@ -29,6 +30,7 @@ public function handle( SupportApprovalEmailService $approvalEmail, SupportActionLogger $logger, CursorAgentService $cursorAgent, + ArtisanCommandRunner $artisanRunner, ): void { $approval = SupportApproval::findOrFail($this->supportApprovalId); @@ -95,6 +97,14 @@ public function handle( 'cursor_agent_status' => $inner['status'] ?? null, 'cursor_pr_url' => $inner['pr_url'] ?? null, ]); + } elseif ($action === 'artisan_command') { + // Re-plan from triage (re-validates against the allowlist/deny-list at + // execution time) rather than trusting the stored approval payload. + $triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []); + $plan = $artisanRunner->planFromTriage($triage); + $result = ($plan['ok'] ?? false) + ? $artisanRunner->execute((array) $plan['result']) + : $plan; } else { $result = [ 'ok' => false, diff --git a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php index f6b4a7e12..0546fd17b 100644 --- a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php +++ b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php @@ -5,6 +5,7 @@ use App\Models\Support\SupportCase; use App\Models\Support\SupportCaseMessage; use App\Services\Support\Agents\DiagnosticsAgentService; +use App\Services\Support\Artisan\ArtisanCommandRunner; use App\Services\Support\SupportActionLogger; use App\Services\Support\SupportJson; use App\Services\Support\UserProfileUpdateService; @@ -28,6 +29,7 @@ public function handle( SupportActionLogger $logger, UserRestoreService $userRestore, UserProfileUpdateService $userProfileUpdate, + ArtisanCommandRunner $artisanRunner, ): void { $case = SupportCase::findOrFail($this->supportCaseId); $case->update(['status' => 'investigating']); @@ -87,6 +89,25 @@ public function handle( ); } + if ($case->case_type === 'artisan_command') { + $triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []); + $plan = $artisanRunner->planFromTriage($triage); + $dryRun = ($plan['ok'] ?? false) + ? $artisanRunner->dryRun((array) $plan['result']) + : $plan; + + $logger->log( + case: $case, + actionName: 'artisan_command', + actionType: 'write', + input: ['dry_run' => true], + output: ['plan' => $plan, 'dry_run' => $dryRun], + succeeded: (bool) ($dryRun['ok'] ?? false) && (bool) ($plan['ok'] ?? false), + executedBy: 'agent', + correlationId: $case->correlation_id, + ); + } + // Persist diagnostics snapshot as a message for UI/debugging (stable storage for later external orchestrator). SupportCaseMessage::create([ 'support_case_id' => $case->id, diff --git a/app/Services/Support/Agents/CursorCliTriageProvider.php b/app/Services/Support/Agents/CursorCliTriageProvider.php index 97e958b1b..73c2d4c18 100644 --- a/app/Services/Support/Agents/CursorCliTriageProvider.php +++ b/app/Services/Support/Agents/CursorCliTriageProvider.php @@ -3,6 +3,7 @@ namespace App\Services\Support\Agents; use App\Models\Support\SupportCase; +use App\Services\Support\Artisan\ArtisanActionRegistry; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; @@ -26,9 +27,14 @@ class CursorCliTriageProvider implements TriageProvider 'certificate_issue', 'role_issue', 'code_change', + 'artisan_command', 'unknown', ]; + public function __construct(private readonly ArtisanActionRegistry $registry) + { + } + public function available(): bool { return (bool) config('support_ai.enabled') @@ -36,6 +42,20 @@ public function available(): bool && trim((string) config('support_ai.cursor_api_key', '')) !== ''; } + private function artisanEnabled(): bool + { + return (bool) config('support_ai.artisan.enabled'); + } + + /** @return list case types offered to the model this run. */ + private function offeredCaseTypes(): array + { + return array_values(array_filter( + self::CASE_TYPES, + fn (string $type) => $type !== 'artisan_command' || $this->artisanEnabled() + )); + } + public function triage(SupportCase $case): ?array { if (!$this->available()) { @@ -88,9 +108,10 @@ public function triage(SupportCase $case): ?array private function buildPrompt(SupportCase $case, string $rawText): string { - $types = implode(', ', self::CASE_TYPES); + $types = implode(', ', $this->offeredCaseTypes()); // Keep the ticket text bounded so the CLI invocation stays small. $ticket = mb_substr($rawText, 0, 6000); + $artisanBlock = $this->artisanEnabled() ? $this->artisanPromptBlock() : ''; return <<", @@ -114,7 +135,10 @@ private function buildPrompt(SupportCase $case, string $rawText): string "profile_lastname": "", "change_summary": "", "change_area": "", - "cursor_prompt": "" + "cursor_prompt": "", + "artisan_command_name": "", + "artisan_args": {}, + "artisan_raw_command": "" } Ticket subject: {$case->subject} @@ -125,6 +149,29 @@ private function buildPrompt(SupportCase $case, string $rawText): string PROMPT; } + private function artisanPromptBlock(): string + { + $lines = []; + foreach ($this->registry->all() as $name => $spec) { + $args = array_keys((array) ($spec['arguments'] ?? [])); + $argList = $args === [] ? '' : ' (args: '.implode(', ', $args).')'; + $lines[] = "- {$name}{$argList} — {$spec['description']}"; + } + $allow = implode("\n", $lines); + + return << 'user_profile_update', 'account_restore' => 'user_restore', 'code_change' => 'code_change', + 'artisan_command' => 'artisan_command', default => null, }; + $artisanArgs = []; + foreach ((array) ($data['artisan_args'] ?? []) as $key => $value) { + if (is_string($key) && (is_string($value) || is_numeric($value) || is_bool($value))) { + $artisanArgs[$key] = is_bool($value) ? $value : (string) $value; + } + } + return [ 'case_type' => $caseType, 'confidence' => $confidence, @@ -228,6 +283,9 @@ private function normalize(array $data): array 'change_summary' => $this->stringOrNull($data['change_summary'] ?? null), 'change_area' => $this->stringOrNull($data['change_area'] ?? null), 'cursor_prompt' => $this->stringOrNull($data['cursor_prompt'] ?? null), + 'artisan_command_name' => $this->stringOrNull($data['artisan_command_name'] ?? null), + 'artisan_args' => $artisanArgs, + 'artisan_raw_command' => $this->stringOrNull($data['artisan_raw_command'] ?? null), 'triage_source' => 'cursor_cli', ]; } diff --git a/app/Services/Support/Artisan/ArtisanActionRegistry.php b/app/Services/Support/Artisan/ArtisanActionRegistry.php new file mode 100644 index 000000000..f056bc9cc --- /dev/null +++ b/app/Services/Support/Artisan/ArtisanActionRegistry.php @@ -0,0 +1,103 @@ +, + * options: array + * }> + */ + public function all(): array + { + return [ + 'support:user-audit' => [ + 'description' => 'Audit a user by email (read-only).', + 'is_write' => false, + 'supports_dry_run' => false, + 'arguments' => ['email' => ['type' => 'email', 'required' => true]], + 'options' => ['--json' => ['type' => 'flag']], + ], + 'support:event-audit' => [ + 'description' => 'Audit an event by identifier/code (read-only).', + 'is_write' => false, + 'supports_dry_run' => false, + 'arguments' => ['identifier' => ['type' => 'token', 'required' => true]], + 'options' => ['--json' => ['type' => 'flag']], + ], + 'support:user-restore' => [ + 'description' => 'Restore a soft-deleted user.', + 'is_write' => true, + 'supports_dry_run' => true, + 'arguments' => ['email' => ['type' => 'email', 'required' => true]], + 'options' => ['--json' => ['type' => 'flag']], + ], + 'support:user-update-profile' => [ + 'description' => 'Update a user first/last name.', + 'is_write' => true, + 'supports_dry_run' => true, + 'arguments' => ['email' => ['type' => 'email', 'required' => true]], + 'options' => [ + '--firstname' => ['type' => 'name'], + '--lastname' => ['type' => 'name'], + '--json' => ['type' => 'flag'], + ], + ], + ]; + } + + public function has(string $command): bool + { + return array_key_exists($command, $this->all()); + } + + /** + * @return array|null + */ + public function get(string $command): ?array + { + return $this->all()[$command] ?? null; + } + + public function isWrite(string $command): bool + { + return (bool) ($this->get($command)['is_write'] ?? false); + } + + public function supportsDryRun(string $command): bool + { + return (bool) ($this->get($command)['supports_dry_run'] ?? false); + } + + /** + * Validate a value against a named arg/option type. + */ + public function validateValue(string $type, mixed $value): bool + { + if ($type === 'flag') { + return true; + } + if (!is_string($value) || trim($value) === '') { + return false; + } + + return match ($type) { + 'email' => filter_var(trim($value), FILTER_VALIDATE_EMAIL) !== false, + // Event codes / identifiers: letters, digits, dot, dash, underscore. + 'token' => (bool) preg_match('/^[A-Za-z0-9._-]{1,64}$/', trim($value)), + // Names: no shell metacharacters or control chars. + 'name' => (bool) preg_match('/^[^\x00-\x1f;|&$`<>\\\\"\']{1,80}$/u', trim($value)), + default => false, + }; + } +} diff --git a/app/Services/Support/Artisan/ArtisanCommandRunner.php b/app/Services/Support/Artisan/ArtisanCommandRunner.php new file mode 100644 index 000000000..474701ecf --- /dev/null +++ b/app/Services/Support/Artisan/ArtisanCommandRunner.php @@ -0,0 +1,267 @@ + $triage + * @return array SupportJson envelope. + */ + public function planFromTriage(array $triage): array + { + $name = is_string($triage['artisan_command_name'] ?? null) ? trim($triage['artisan_command_name']) : ''; + $raw = is_string($triage['artisan_raw_command'] ?? null) ? trim($triage['artisan_raw_command']) : ''; + $args = (array) ($triage['artisan_args'] ?? []); + + return $this->plan($name !== '' ? $name : null, $args, $raw !== '' ? $raw : null); + } + + /** + * Build a validated execution plan from triage output. + * + * @param array $args + * @return array SupportJson envelope; result holds the plan. + */ + public function plan(?string $commandName, array $args = [], ?string $rawCommand = null): array + { + if (!$this->enabled()) { + return SupportJson::fail('artisan_plan', [], 'artisan_actions_disabled'); + } + + if ($commandName !== null && $commandName !== '') { + return $this->planRegistry($commandName, $args); + } + + if ($rawCommand !== null && trim($rawCommand) !== '') { + return $this->planRaw($rawCommand); + } + + return SupportJson::fail('artisan_plan', [], 'no_command_proposed'); + } + + /** + * @param array $args + */ + private function planRegistry(string $commandName, array $args): array + { + $spec = $this->registry->get($commandName); + if ($spec === null) { + return SupportJson::fail('artisan_plan', ['command' => $commandName], 'command_not_in_allowlist'); + } + + $tokens = [$commandName]; + + foreach ((array) ($spec['arguments'] ?? []) as $name => $rule) { + $value = $args[$name] ?? null; + if ($value === null || $value === '') { + if ($rule['required'] ?? false) { + return SupportJson::fail('artisan_plan', ['command' => $commandName], 'missing_argument:'.$name); + } + continue; + } + if (!$this->registry->validateValue($rule['type'], $value)) { + return SupportJson::fail('artisan_plan', ['command' => $commandName], 'invalid_argument:'.$name); + } + $tokens[] = (string) $value; + } + + foreach ((array) ($spec['options'] ?? []) as $opt => $rule) { + $key = ltrim($opt, '-'); + $value = $args[$key] ?? ($args[$opt] ?? null); + if ($value === null || $value === false || $value === '') { + continue; + } + if (($rule['type'] ?? '') === 'flag') { + $tokens[] = $opt; + continue; + } + if (!$this->registry->validateValue($rule['type'], $value)) { + return SupportJson::fail('artisan_plan', ['command' => $commandName], 'invalid_option:'.$key); + } + $tokens[] = $opt.'='.$value; + } + + return SupportJson::ok('artisan_plan', ['command' => $commandName], [ + 'mode' => 'registry', + 'command' => $commandName, + 'tokens' => $tokens, + 'is_write' => (bool) ($spec['is_write'] ?? false), + 'supports_dry_run' => (bool) ($spec['supports_dry_run'] ?? false), + 'display' => $this->display($tokens), + ]); + } + + private function planRaw(string $rawCommand): array + { + if (!(bool) config('support_ai.artisan.allow_raw_fallback', true)) { + return SupportJson::fail('artisan_plan', [], 'raw_fallback_disabled'); + } + + $raw = trim($rawCommand); + // Strip an accidental "php artisan" / "artisan" prefix. + $raw = preg_replace('/^\s*(php\s+)?artisan\s+/i', '', $raw) ?? $raw; + + if (preg_match('/[;|&$`<>\n\r\t\\\\"\']/', $raw)) { + return SupportJson::fail('artisan_plan', ['raw' => $raw], 'shell_metacharacters_rejected'); + } + + $tokens = array_values(array_filter(explode(' ', $raw), fn ($t) => $t !== '')); + if ($tokens === []) { + return SupportJson::fail('artisan_plan', [], 'empty_raw_command'); + } + + $subcommand = strtolower($tokens[0]); + if (!preg_match('/^[a-z][a-z0-9:_-]*$/', $subcommand)) { + return SupportJson::fail('artisan_plan', ['raw' => $raw], 'invalid_subcommand'); + } + + foreach ((array) config('support_ai.artisan.denylist', []) as $denied) { + $denied = strtolower((string) $denied); + if ($subcommand === $denied || Str::startsWith($subcommand, $denied)) { + return SupportJson::fail('artisan_plan', ['raw' => $raw], 'denylisted_command:'.$subcommand); + } + } + + return SupportJson::ok('artisan_plan', ['raw' => $raw], [ + 'mode' => 'raw', + 'command' => $subcommand, + 'tokens' => $tokens, + // Raw commands are treated as writes and are never auto-simulated. + 'is_write' => true, + 'supports_dry_run' => false, + 'display' => $this->display($tokens), + ]); + } + + /** + * Produce the dry-run preview for a plan. + * + * @param array $plan + * @return array SupportJson envelope. + */ + public function dryRun(array $plan): array + { + $tokens = (array) ($plan['tokens'] ?? []); + $input = ['command' => $plan['display'] ?? $this->display($tokens)]; + + // Raw commands cannot be safely simulated — present the exact command only. + if (($plan['mode'] ?? '') === 'raw') { + return SupportJson::ok('artisan_dry_run', $input, [ + 'executed' => false, + 'command' => $plan['display'] ?? $this->display($tokens), + 'note' => 'Raw command cannot be simulated. The exact command above will run on APPROVE.', + ]); + } + + // Write commands: append --dry-run. Read-only commands: safe to run as-is. + $runTokens = $tokens; + if (($plan['is_write'] ?? false) && ($plan['supports_dry_run'] ?? false)) { + $runTokens[] = '--dry-run'; + } + + $result = $this->runArtisan($runTokens); + + return SupportJson::ok('artisan_dry_run', $input, [ + 'executed' => true, + 'command' => $this->display($runTokens), + 'exit_code' => $result['exit_code'], + 'output' => $result['output'], + ]); + } + + /** + * Execute the plan for real (post-APPROVE). + * + * @param array $plan + * @return array SupportJson envelope. + */ + public function execute(array $plan): array + { + if (!$this->enabled()) { + return SupportJson::fail('artisan_execute', [], 'artisan_actions_disabled'); + } + + $tokens = (array) ($plan['tokens'] ?? []); + if ($tokens === []) { + return SupportJson::fail('artisan_execute', [], 'empty_plan'); + } + + $result = $this->runArtisan($tokens); + $input = ['command' => $this->display($tokens)]; + + if ($result['exit_code'] !== 0) { + $envelope = SupportJson::fail('artisan_execute', $input, 'exit_code_'.$result['exit_code']); + $envelope['result'] = [ + 'command' => $this->display($tokens), + 'exit_code' => $result['exit_code'], + 'output' => $result['output'], + ]; + + return $envelope; + } + + return SupportJson::ok('artisan_execute', $input, [ + 'command' => $this->display($tokens), + 'exit_code' => $result['exit_code'], + 'output' => $result['output'], + ]); + } + + /** + * @param list $tokens + * @return array{exit_code: int, output: string} + */ + private function runArtisan(array $tokens): array + { + $php = (string) config('support_ai.artisan.php_binary', PHP_BINARY); + $argv = array_merge([$php, base_path('artisan')], array_values($tokens)); + + $result = Process::timeout((int) config('support_ai.artisan.timeout_seconds', 120)) + ->path(base_path()) + ->run($argv); + + $output = trim($result->output()."\n".$result->errorOutput()); + $limit = (int) config('support_ai.artisan.output_limit', 8000); + if (mb_strlen($output) > $limit) { + $output = mb_substr($output, 0, $limit)."\n…(truncated)"; + } + + return ['exit_code' => $result->exitCode() ?? 1, 'output' => $output]; + } + + /** + * @param list $tokens + */ + private function display(array $tokens): string + { + return 'php artisan '.implode(' ', $tokens); + } +} diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index e8dc8aedb..7715e344b 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -25,6 +25,7 @@ public function approvalSubject(SupportCase $case): string 'profile_update' => 'Please review — name change', 'account_restore' => 'Please review — account restore', 'code_change' => 'Please review — proposed code fix (PR into dev)', + 'artisan_command' => 'Please review — proposed server maintenance command', default => 'Please review before we make changes', }; @@ -293,9 +294,41 @@ private function proposedActionForCase(SupportCase $case): array } } + if ($case->case_type === 'artisan_command') { + $stored = $this->artisanDiagnostics($case); + $planResult = is_array($stored['plan']['result'] ?? null) ? $stored['plan']['result'] : []; + if (($stored['plan']['ok'] ?? false) && ($planResult['display'] ?? '') !== '') { + return [ + 'action' => 'artisan_command', + 'payload' => [ + 'display' => (string) $planResult['display'], + 'mode' => (string) ($planResult['mode'] ?? 'registry'), + 'command' => (string) ($planResult['command'] ?? ''), + 'is_write' => (bool) ($planResult['is_write'] ?? true), + ], + ]; + } + } + return ['action' => 'none', 'payload' => []]; } + /** + * Read the artisan_command dry-run record from diagnostics. + * + * @return array + */ + private function artisanDiagnostics(SupportCase $case): array + { + $output = $case->actions() + ->where('action_name', 'artisan_command') + ->where('action_type', 'write') + ->latest() + ->first()?->output_json; + + return is_array($output) ? $output : []; + } + /** * Read the code_change dry-run plan recorded during diagnostics. * @@ -425,6 +458,10 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi return $this->dryRunCodeChangeLines($payload); } + if ($action === 'artisan_command') { + return $this->dryRunArtisanLines($case); + } + return [ '', 'We could not determine an automatic change from this email.', @@ -432,6 +469,47 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi ]; } + /** + * @return list + */ + private function dryRunArtisanLines(SupportCase $case): array + { + $stored = $this->artisanDiagnostics($case); + $planResult = is_array($stored['plan']['result'] ?? null) ? $stored['plan']['result'] : []; + $dry = is_array($stored['dry_run']['result'] ?? null) ? $stored['dry_run']['result'] : []; + + $display = (string) ($planResult['display'] ?? ''); + $lines = ['', 'We will run this maintenance command on the server:']; + if ($display !== '') { + $lines[] = ' '.$display; + } + + if (($planResult['mode'] ?? 'registry') === 'raw') { + $lines[] = ''; + $lines[] = '(AI-proposed command — not on the standard allowlist. Please check it carefully.)'; + } + + if (($dry['executed'] ?? false) && isset($dry['output'])) { + $lines[] = ''; + $lines[] = 'Dry-run preview output:'; + $lines[] = str_repeat('·', 20); + foreach (preg_split('/\r\n|\r|\n/', (string) $dry['output']) ?: [] as $out) { + $lines[] = ' '.$out; + } + } elseif (($dry['note'] ?? '') !== '') { + $lines[] = ''; + $lines[] = (string) $dry['note']; + } + + if (!($stored['plan']['ok'] ?? false)) { + $errors = implode('; ', array_map('strval', (array) ($stored['plan']['errors'] ?? []))); + $lines[] = ''; + $lines[] = 'Note: the proposed command could not be prepared'.($errors !== '' ? ' ('.$errors.')' : '').'.'; + } + + return $lines; + } + /** * @param array $payload * @return list @@ -535,6 +613,7 @@ private function completionHeadline(SupportCase $case, string $action, bool $suc 'user_profile_update' => 'Done — name updated on CodeWeek account', 'user_restore' => 'Done — CodeWeek account reactivated', 'code_change' => 'Started — AI coding agent is preparing a PR into dev', + 'artisan_command' => 'Done — maintenance command completed on the server', default => 'Done — your approved request was completed', }; } @@ -672,6 +751,26 @@ private function completionSuccessLines(SupportCase $case, string $action, array return $lines; } + if ($action === 'artisan_command') { + $command = (string) ($inner['command'] ?? ''); + $output = (string) ($inner['output'] ?? ''); + $lines = ['We ran the approved maintenance command on the server.']; + if ($command !== '') { + $lines[] = ''; + $lines[] = 'Command: '.$command; + } + if ($output !== '') { + $lines[] = ''; + $lines[] = 'Output:'; + $lines[] = str_repeat('·', 20); + foreach (preg_split('/\r\n|\r|\n/', $output) ?: [] as $out) { + $lines[] = ' '.$out; + } + } + + return $lines; + } + return [ 'The approved request for case #'.$case->id.' was completed successfully.', ]; @@ -746,6 +845,11 @@ private function humanizeError(string $code, string $action, int $caseId): strin str_contains($code, 'dry_run_mode') => 'The system is in preview-only mode and could not apply live changes.', str_contains($code, 'unsupported_approved_action') => 'This type of request cannot be run automatically yet.', str_contains($code, 'approval_required') => 'This action still requires a separate approval step in the system.', + str_contains($code, 'denylisted_command') => 'The proposed command is blocked for safety and was not run.', + str_contains($code, 'artisan_actions_disabled') => 'Server maintenance commands are currently disabled.', + str_contains($code, 'command_not_in_allowlist') => 'The proposed command is not on the approved list and was not run.', + str_contains($code, 'shell_metacharacters_rejected') => 'The proposed command contained unsafe characters and was not run.', + str_starts_with($code, 'exit_code_') => 'The command ran but reported an error (exit '.str_replace('exit_code_', '', $code).'). Please check the case in Nova.', $action === 'user_restore' && str_contains($code, 'verification') => 'The account was changed but we could not confirm it is fully active. Please verify in Nova.', default => 'Technical detail: '.$code, }; diff --git a/config/support_ai.php b/config/support_ai.php index e3c40cf2e..bee90bd10 100644 --- a/config/support_ai.php +++ b/config/support_ai.php @@ -60,4 +60,28 @@ // promotion is skipped gracefully if absent). owner/repo form. 'github_repo' => env('SUPPORT_GITHUB_REPO', 'codeeu/codeweek'), 'github_token' => env('SUPPORT_GITHUB_TOKEN'), + + /* + |-------------------------------------------------------------------------- + | Phase 2 — AI-driven artisan changes on the server + |-------------------------------------------------------------------------- + | Allowlist-first: the AI may only pick an allowlisted command (see + | App\Services\Support\Artisan\ArtisanActionRegistry). If none fits and + | allow_raw_fallback is true, it may propose a raw artisan command — still + | dry-run + emailed APPROVE, and still subject to the destructive deny-list. + */ + 'artisan' => [ + 'enabled' => env('SUPPORT_AI_ARTISAN_ENABLED', false), + 'allow_raw_fallback' => filter_var(env('SUPPORT_AI_ARTISAN_ALLOW_RAW', true), FILTER_VALIDATE_BOOL), + 'timeout_seconds' => (int) env('SUPPORT_AI_ARTISAN_TIMEOUT', 120), + 'output_limit' => (int) env('SUPPORT_AI_ARTISAN_OUTPUT_LIMIT', 8000), + 'php_binary' => env('SUPPORT_AI_PHP_BINARY', PHP_BINARY), + + // Subcommand verbs that are NEVER allowed, even via raw fallback. + 'denylist' => [ + 'migrate:fresh', 'migrate:reset', 'migrate:rollback', 'migrate:refresh', + 'db:wipe', 'db:seed', 'tinker', 'down', 'env', 'key:generate', + 'config:cache', 'route:cache', 'optimize', + ], + ], ]; diff --git a/config/support_gmail.php b/config/support_gmail.php index dbee90f52..8a165ecd2 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -78,6 +78,7 @@ 'user_restore', 'user_profile_update', 'code_change', + 'artisan_command', ], // Send a follow-up email after an APPROVE action runs (success or failure). diff --git a/docs/support-copilot-ai.md b/docs/support-copilot-ai.md index 56812c745..de7acd4d8 100644 --- a/docs/support-copilot-ai.md +++ b/docs/support-copilot-ai.md @@ -1,7 +1,7 @@ # CodeWeek Support Copilot — AI capabilities (Phase 1) **Status:** Phase 1 · AI triage + frontend code fixes as PRs into `dev` -**Phase 2 (planned):** AI-driven `artisan` changes on the server (allowlist-first, dry-run + APPROVE) +**Phase 2:** AI-driven `artisan` changes on the server (allowlist-first, dry-run + APPROVE) This builds on the email pipeline in [support-copilot-stakeholder-guide.md](./support-copilot-stakeholder-guide.md) and the action matrix in [support-copilot-allowed-actions.md](./support-copilot-allowed-actions.md). @@ -108,10 +108,36 @@ Keep `SUPPORT_GMAIL_DRY_RUN=true` throughout so every change still needs an emai --- -## Phase 2 (not yet built) — AI `artisan` changes over SSH - -Agreed design for the next phase: +## Phase 2 — AI `artisan` changes over SSH + +When triage classifies a ticket as `artisan_command`, the bot prepares a server +maintenance command and runs it through the same dry-run → APPROVE → execute → +report pipeline as every other write action. **Disabled by default** +(`SUPPORT_AI_ARTISAN_ENABLED=false`). + +- **Allowlist-first** (`App\Services\Support\Artisan\ArtisanActionRegistry`): the AI may + only pick a permitted command, and every argument/option is validated by type + (email, token, name). Current allowlist: `support:user-audit`, `support:event-audit`, + `support:user-restore`, `support:user-update-profile`. +- **Guarded raw fallback** (`SUPPORT_AI_ARTISAN_ALLOW_RAW=true`): if no allowlisted command + fits, the AI may propose a bare `artisan` command. It is rejected if it contains shell + metacharacters or hits the deny-list (`migrate:fresh`, `db:wipe`, `tinker`, `down`, …), + treated as a write, and **never auto-simulated** — the exact command is emailed for APPROVE. +- **Execution safety:** commands run via the `Process` array form (`php artisan …`), so + argument values can never be interpreted by a shell. Write commands that support + `--dry-run` are previewed with it during diagnostics; read-only commands are run as-is. + Re-validated against the allowlist/deny-list again at execution time (not trusting the + stored approval payload). Output is captured and truncated to `SUPPORT_AI_ARTISAN_OUTPUT_LIMIT`. +- **Report:** the completion email shows the exact command and its output. + +### Phase 2 env + +```dotenv +SUPPORT_AI_ARTISAN_ENABLED=false # master switch for artisan actions +SUPPORT_AI_ARTISAN_ALLOW_RAW=true # allow AI-proposed (non-allowlisted) commands +SUPPORT_AI_ARTISAN_TIMEOUT=120 # per-command timeout (seconds) +SUPPORT_AI_ARTISAN_OUTPUT_LIMIT=8000 # captured output cap (characters) +``` -- **Allowlist-first:** an `ArtisanActionRegistry` of permitted commands with validated args. The AI may only pick from these. -- **Fallback:** if no allowlisted command fits, the AI may propose a raw command string — still **dry-run first**, the **exact command** is emailed, and it only runs after **APPROVE**. -- **Report:** after execution, a completion email reports what ran and the result (reusing the existing completion-email pipeline). +`artisan_command` must also be present in `support_gmail.allowed_write_actions` +(it is by default) and `support:ai:setup-check` verifies this. diff --git a/docs/support-copilot-allowed-actions.md b/docs/support-copilot-allowed-actions.md index 30b0329a9..b1b945cac 100644 --- a/docs/support-copilot-allowed-actions.md +++ b/docs/support-copilot-allowed-actions.md @@ -37,6 +37,7 @@ These are the **only** actions that can change production data via the email pip | `user_restore` | `account_restore` | Restores a **soft-deleted** user | User email + words like *restore*, *deleted account* | | `user_profile_update` | `profile_update` | Updates `firstname` and/or `lastname` on `users` | User email + requested first/last name | | `code_change` | `code_change` | AI cloud agent implements a frontend/code fix and opens a **PR into `dev`** (never deploys) | Description of the bug/change in the website code | +| `artisan_command` | `artisan_command` | Runs an allowlisted (or guarded AI-proposed) `artisan` maintenance command on the server after dry-run + APPROVE | Request needing a server maintenance command; gated by `SUPPORT_AI_ARTISAN_ENABLED` | Configured in `config/support_gmail.php` → `allowed_write_actions`. AI features are configured in `config/support_ai.php` — see [support-copilot-ai.md](./support-copilot-ai.md). diff --git a/tests/Unit/Support/ArtisanCommandRunnerTest.php b/tests/Unit/Support/ArtisanCommandRunnerTest.php new file mode 100644 index 000000000..ebc0f059e --- /dev/null +++ b/tests/Unit/Support/ArtisanCommandRunnerTest.php @@ -0,0 +1,131 @@ +set('support_ai.enabled', true); + config()->set('support_ai.artisan.enabled', true); + config()->set('support_ai.artisan.allow_raw_fallback', true); + config()->set('support_ai.artisan.denylist', [ + 'migrate:fresh', 'db:wipe', 'tinker', 'down', + ]); + + $this->runner = new ArtisanCommandRunner(new ArtisanActionRegistry()); + } + + public function test_disabled_when_flag_off(): void + { + config()->set('support_ai.artisan.enabled', false); + + $plan = $this->runner->plan('support:user-restore', ['email' => 'a@b.com']); + + $this->assertFalse($plan['ok']); + $this->assertContains('artisan_actions_disabled', $plan['errors']); + } + + public function test_registry_command_builds_validated_tokens(): void + { + $plan = $this->runner->plan('support:user-restore', ['email' => 'user@example.com', '--json' => true]); + + $this->assertTrue($plan['ok']); + $this->assertSame('registry', $plan['result']['mode']); + $this->assertTrue($plan['result']['is_write']); + $this->assertTrue($plan['result']['supports_dry_run']); + $this->assertSame(['support:user-restore', 'user@example.com', '--json'], $plan['result']['tokens']); + $this->assertSame('php artisan support:user-restore user@example.com --json', $plan['result']['display']); + } + + public function test_registry_command_rejects_invalid_email(): void + { + $plan = $this->runner->plan('support:user-restore', ['email' => 'not-an-email']); + + $this->assertFalse($plan['ok']); + $this->assertContains('invalid_argument:email', $plan['errors']); + } + + public function test_registry_command_requires_required_argument(): void + { + $plan = $this->runner->plan('support:user-restore', []); + + $this->assertFalse($plan['ok']); + $this->assertContains('missing_argument:email', $plan['errors']); + } + + public function test_unknown_command_is_rejected(): void + { + $plan = $this->runner->plan('queue:flush', []); + + $this->assertFalse($plan['ok']); + $this->assertContains('command_not_in_allowlist', $plan['errors']); + } + + public function test_raw_fallback_allows_safe_command(): void + { + $plan = $this->runner->plan(null, [], 'cache:clear'); + + $this->assertTrue($plan['ok']); + $this->assertSame('raw', $plan['result']['mode']); + $this->assertTrue($plan['result']['is_write']); + $this->assertFalse($plan['result']['supports_dry_run']); + $this->assertSame('php artisan cache:clear', $plan['result']['display']); + } + + public function test_raw_fallback_strips_php_artisan_prefix(): void + { + $plan = $this->runner->plan(null, [], 'php artisan view:clear'); + + $this->assertTrue($plan['ok']); + $this->assertSame(['view:clear'], $plan['result']['tokens']); + } + + public function test_raw_fallback_blocks_denylisted_command(): void + { + $plan = $this->runner->plan(null, [], 'migrate:fresh'); + + $this->assertFalse($plan['ok']); + $this->assertContains('denylisted_command:migrate:fresh', $plan['errors']); + } + + public function test_raw_fallback_rejects_shell_metacharacters(): void + { + foreach (['cache:clear && rm -rf /', 'cache:clear; ls', 'cache:clear | cat', 'cache:clear `whoami`'] as $evil) { + $plan = $this->runner->plan(null, [], $evil); + $this->assertFalse($plan['ok'], $evil); + $this->assertContains('shell_metacharacters_rejected', $plan['errors'], $evil); + } + } + + public function test_raw_fallback_can_be_disabled(): void + { + config()->set('support_ai.artisan.allow_raw_fallback', false); + + $plan = $this->runner->plan(null, [], 'cache:clear'); + + $this->assertFalse($plan['ok']); + $this->assertContains('raw_fallback_disabled', $plan['errors']); + } + + public function test_plan_from_triage_prefers_registry_command(): void + { + $plan = $this->runner->planFromTriage([ + 'artisan_command_name' => 'support:user-audit', + 'artisan_args' => ['email' => 'user@example.com'], + 'artisan_raw_command' => 'cache:clear', + ]); + + $this->assertTrue($plan['ok']); + $this->assertSame('support:user-audit', $plan['result']['command']); + $this->assertFalse($plan['result']['is_write']); + } +}