diff --git a/Console/Commands/PlanCommand.php b/Console/Commands/PlanCommand.php index e40200c..e813d06 100644 --- a/Console/Commands/PlanCommand.php +++ b/Console/Commands/PlanCommand.php @@ -90,7 +90,7 @@ class PlanCommand extends Command $query->notArchived(); } - $plans = $query->orderByRaw("FIELD(status, 'active', 'draft', 'completed', 'archived')") + $plans = $query->orderByStatus() ->orderBy('updated_at', 'desc') ->limit($this->option('limit')) ->get(); diff --git a/Console/Commands/TaskCommand.php b/Console/Commands/TaskCommand.php index 8d5f283..e26ea95 100644 --- a/Console/Commands/TaskCommand.php +++ b/Console/Commands/TaskCommand.php @@ -86,8 +86,8 @@ class TaskCommand extends Command $query->where('category', $category); } - $tasks = $query->orderByRaw("FIELD(priority, 'urgent', 'high', 'normal', 'low')") - ->orderByRaw("FIELD(status, 'in_progress', 'pending', 'done')") + $tasks = $query->orderByPriority() + ->orderByStatus() ->limit($this->option('limit')) ->get(); diff --git a/Mcp/Tools/Agent/Plan/PlanGet.php b/Mcp/Tools/Agent/Plan/PlanGet.php index d9a8ade..4b29352 100644 --- a/Mcp/Tools/Agent/Plan/PlanGet.php +++ b/Mcp/Tools/Agent/Plan/PlanGet.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; +use Core\Mcp\Dependencies\ToolDependency; use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; @@ -16,6 +17,20 @@ class PlanGet extends AgentTool protected array $scopes = ['read']; + /** + * Get the dependencies for this tool. + * + * Workspace context is required to ensure tenant isolation. + * + * @return array + */ + public function dependencies(): array + { + return [ + ToolDependency::contextExists('workspace_id', 'Workspace context required for plan operations'), + ]; + } + public function name(): string { return 'plan_get'; @@ -53,11 +68,19 @@ class PlanGet extends AgentTool return $this->error($e->getMessage()); } + // Validate workspace context for tenant isolation + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required for plan operations'); + } + $format = $this->optional($args, 'format', 'json'); // Use circuit breaker for Agentic module database calls - return $this->withCircuitBreaker('agentic', function () use ($slug, $format) { + return $this->withCircuitBreaker('agentic', function () use ($slug, $format, $workspaceId) { + // Query plan with workspace scope to prevent cross-tenant access $plan = AgentPlan::with('agentPhases') + ->forWorkspace($workspaceId) ->where('slug', $slug) ->first(); @@ -66,10 +89,10 @@ class PlanGet extends AgentTool } if ($format === 'markdown') { - return ['markdown' => $plan->toMarkdown()]; + return $this->success(['markdown' => $plan->toMarkdown()]); } - return [ + return $this->success([ 'plan' => [ 'slug' => $plan->slug, 'title' => $plan->title, @@ -88,7 +111,7 @@ class PlanGet extends AgentTool 'created_at' => $plan->created_at->toIso8601String(), 'updated_at' => $plan->updated_at->toIso8601String(), ], - ]; + ]); }, fn () => $this->error('Agentic service temporarily unavailable', 'service_unavailable')); } } diff --git a/Mcp/Tools/Agent/Plan/PlanList.php b/Mcp/Tools/Agent/Plan/PlanList.php index 2067140..3c4af0d 100644 --- a/Mcp/Tools/Agent/Plan/PlanList.php +++ b/Mcp/Tools/Agent/Plan/PlanList.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; +use Core\Mcp\Dependencies\ToolDependency; use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; @@ -16,6 +17,20 @@ class PlanList extends AgentTool protected array $scopes = ['read']; + /** + * Get the dependencies for this tool. + * + * Workspace context is required to ensure tenant isolation. + * + * @return array + */ + public function dependencies(): array + { + return [ + ToolDependency::contextExists('workspace_id', 'Workspace context required for plan operations'), + ]; + } + public function name(): string { return 'plan_list'; @@ -53,7 +68,15 @@ class PlanList extends AgentTool return $this->error($e->getMessage()); } + // Validate workspace context for tenant isolation + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required for plan operations'); + } + + // Query plans with workspace scope to prevent cross-tenant access $query = AgentPlan::with('agentPhases') + ->forWorkspace($workspaceId) ->orderBy('updated_at', 'desc'); if (! $includeArchived && $status !== 'archived') { @@ -66,7 +89,7 @@ class PlanList extends AgentTool $plans = $query->get(); - return [ + return $this->success([ 'plans' => $plans->map(fn ($plan) => [ 'slug' => $plan->slug, 'title' => $plan->title, @@ -75,6 +98,6 @@ class PlanList extends AgentTool 'updated_at' => $plan->updated_at->toIso8601String(), ])->all(), 'total' => $plans->count(), - ]; + ]); } } diff --git a/Mcp/Tools/Agent/State/StateGet.php b/Mcp/Tools/Agent/State/StateGet.php index e01974f..0304acd 100644 --- a/Mcp/Tools/Agent/State/StateGet.php +++ b/Mcp/Tools/Agent/State/StateGet.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\State; +use Core\Mcp\Dependencies\ToolDependency; use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; @@ -16,6 +17,20 @@ class StateGet extends AgentTool protected array $scopes = ['read']; + /** + * Get the dependencies for this tool. + * + * Workspace context is required to ensure tenant isolation. + * + * @return array + */ + public function dependencies(): array + { + return [ + ToolDependency::contextExists('workspace_id', 'Workspace context required for state operations'), + ]; + } + public function name(): string { return 'state_get'; @@ -53,7 +68,16 @@ class StateGet extends AgentTool return $this->error($e->getMessage()); } - $plan = AgentPlan::where('slug', $planSlug)->first(); + // Validate workspace context for tenant isolation + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required for state operations'); + } + + // Query plan with workspace scope to prevent cross-tenant access + $plan = AgentPlan::forWorkspace($workspaceId) + ->where('slug', $planSlug) + ->first(); if (! $plan) { return $this->error("Plan not found: {$planSlug}"); @@ -65,11 +89,11 @@ class StateGet extends AgentTool return $this->error("State not found: {$key}"); } - return [ + return $this->success([ 'key' => $state->key, 'value' => $state->value, 'category' => $state->category, 'updated_at' => $state->updated_at->toIso8601String(), - ]; + ]); } } diff --git a/Mcp/Tools/Agent/State/StateList.php b/Mcp/Tools/Agent/State/StateList.php index d4ff589..2893dc7 100644 --- a/Mcp/Tools/Agent/State/StateList.php +++ b/Mcp/Tools/Agent/State/StateList.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\State; +use Core\Mcp\Dependencies\ToolDependency; use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; @@ -16,6 +17,20 @@ class StateList extends AgentTool protected array $scopes = ['read']; + /** + * Get the dependencies for this tool. + * + * Workspace context is required to ensure tenant isolation. + * + * @return array + */ + public function dependencies(): array + { + return [ + ToolDependency::contextExists('workspace_id', 'Workspace context required for state operations'), + ]; + } + public function name(): string { return 'state_list'; @@ -52,7 +67,16 @@ class StateList extends AgentTool return $this->error($e->getMessage()); } - $plan = AgentPlan::where('slug', $planSlug)->first(); + // Validate workspace context for tenant isolation + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required for state operations'); + } + + // Query plan with workspace scope to prevent cross-tenant access + $plan = AgentPlan::forWorkspace($workspaceId) + ->where('slug', $planSlug) + ->first(); if (! $plan) { return $this->error("Plan not found: {$planSlug}"); @@ -67,13 +91,13 @@ class StateList extends AgentTool $states = $query->get(); - return [ + return $this->success([ 'states' => $states->map(fn ($state) => [ 'key' => $state->key, 'value' => $state->value, 'category' => $state->category, ])->all(), 'total' => $states->count(), - ]; + ]); } } diff --git a/Mcp/Tools/Agent/State/StateSet.php b/Mcp/Tools/Agent/State/StateSet.php index b231545..f777e45 100644 --- a/Mcp/Tools/Agent/State/StateSet.php +++ b/Mcp/Tools/Agent/State/StateSet.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\State; +use Core\Mcp\Dependencies\ToolDependency; use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentWorkspaceState; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; @@ -17,6 +18,20 @@ class StateSet extends AgentTool protected array $scopes = ['write']; + /** + * Get the dependencies for this tool. + * + * Workspace context is required to ensure tenant isolation. + * + * @return array + */ + public function dependencies(): array + { + return [ + ToolDependency::contextExists('workspace_id', 'Workspace context required for state operations'), + ]; + } + public function name(): string { return 'state_set'; @@ -63,7 +78,16 @@ class StateSet extends AgentTool return $this->error($e->getMessage()); } - $plan = AgentPlan::where('slug', $planSlug)->first(); + // Validate workspace context for tenant isolation + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required for state operations'); + } + + // Query plan with workspace scope to prevent cross-tenant access + $plan = AgentPlan::forWorkspace($workspaceId) + ->where('slug', $planSlug) + ->first(); if (! $plan) { return $this->error("Plan not found: {$planSlug}"); diff --git a/Models/AgentPlan.php b/Models/AgentPlan.php index 3b0e084..68fd2d8 100644 --- a/Models/AgentPlan.php +++ b/Models/AgentPlan.php @@ -113,6 +113,23 @@ class AgentPlan extends Model return $query->where('status', '!=', self::STATUS_ARCHIVED); } + /** + * Order by status using CASE statement with whitelisted values. + * + * This is a safe replacement for orderByRaw("FIELD(status, ...)") which + * could be vulnerable to SQL injection if extended with user input. + */ + public function scopeOrderByStatus($query, string $direction = 'asc') + { + return $query->orderByRaw('CASE status + WHEN ? THEN 1 + WHEN ? THEN 2 + WHEN ? THEN 3 + WHEN ? THEN 4 + ELSE 5 + END ' . ($direction === 'desc' ? 'DESC' : 'ASC'), [self::STATUS_ACTIVE, self::STATUS_DRAFT, self::STATUS_COMPLETED, self::STATUS_ARCHIVED]); + } + // Helpers public static function generateSlug(string $title): string { diff --git a/Models/Task.php b/Models/Task.php index 5d673af..5687a56 100644 --- a/Models/Task.php +++ b/Models/Task.php @@ -46,6 +46,39 @@ class Task extends Model return $query->whereIn('status', ['pending', 'in_progress']); } + /** + * Order by priority using CASE statement with whitelisted values. + * + * This is a safe replacement for orderByRaw("FIELD(priority, ...)") which + * could be vulnerable to SQL injection if extended with user input. + */ + public function scopeOrderByPriority($query, string $direction = 'asc') + { + return $query->orderByRaw('CASE priority + WHEN ? THEN 1 + WHEN ? THEN 2 + WHEN ? THEN 3 + WHEN ? THEN 4 + ELSE 5 + END ' . ($direction === 'desc' ? 'DESC' : 'ASC'), ['urgent', 'high', 'normal', 'low']); + } + + /** + * Order by status using CASE statement with whitelisted values. + * + * This is a safe replacement for orderByRaw("FIELD(status, ...)") which + * could be vulnerable to SQL injection if extended with user input. + */ + public function scopeOrderByStatus($query, string $direction = 'asc') + { + return $query->orderByRaw('CASE status + WHEN ? THEN 1 + WHEN ? THEN 2 + WHEN ? THEN 3 + ELSE 4 + END ' . ($direction === 'desc' ? 'DESC' : 'ASC'), ['in_progress', 'pending', 'done']); + } + public function getStatusBadgeAttribute(): string { return match ($this->status) { diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..508ed89 --- /dev/null +++ b/TODO.md @@ -0,0 +1,283 @@ +# TODO.md - core-agentic + +Production-quality task list for the AI agent orchestration package. + +**Last updated:** 2026-01-29 + +--- + +## P1 - Critical / Security + +### Security Hardening + +- [ ] **SEC-001: API key hashing uses SHA-256 without salt** + - Location: `Models/AgentApiKey.php::generate()` + - Risk: Weak credential storage vulnerable to rainbow table attacks + - Fix: Use `password_hash()` with Argon2id or add random salt + - Acceptance: API keys use salted hashing, existing keys migrated + +- [x] **SEC-002: SQL injection risk in TaskCommand orderByRaw** (FIXED 2026-01-29) + - Location: `Console/Commands/TaskCommand.php` + - Risk: `orderByRaw("FIELD(priority, ...)")` pattern vulnerable if extended + - Fix: Replaced with parameterised `orderByPriority()` and `orderByStatus()` scopes + - Also fixed `PlanCommand.php` with same pattern + +- [x] **SEC-003: StateSet tool lacks workspace scoping** (FIXED 2026-01-29) + - Location: `Mcp/Tools/Agent/State/StateSet.php` + - Risk: Plan lookup by slug without workspace constraint - cross-tenant data access + - Fix: Added workspace_id context check and forWorkspace() scoping to: + - `StateSet.php`, `StateGet.php`, `StateList.php` + - `PlanGet.php`, `PlanList.php` + - Added ToolDependency for workspace_id requirement + +- [ ] **SEC-004: Missing rate limiting on MCP tool execution** + - Location: `Services/AgentToolRegistry.php::execute()` + - Risk: API key rate limits apply to auth, not individual tool calls + - Fix: Add per-tool rate limiting in execute() method + - Acceptance: Tool execution respects rate limits per workspace + +### Input Validation + +- [ ] **VAL-001: Template variable injection vulnerability** + - Location: `Services/PlanTemplateService.php::substituteVariables()` + - Risk: Special characters in variables could corrupt JSON structure + - Status: Partially fixed with escapeForJson, but needs additional input sanitisation + - Fix: Validate variable values against allowed character sets + - Acceptance: Malicious variable values rejected with clear error + +--- + +## P2 - High Priority + +### Test Coverage (Critical Gap) + +- [ ] **TEST-001: Add AgentApiKey model tests** + - Create `tests/Feature/AgentApiKeyTest.php` + - Cover: generation, validation, permissions, rate limiting, IP restrictions + - Note: Only model without dedicated test file + +- [ ] **TEST-002: Add AgentApiKeyService tests** + - Create `tests/Feature/AgentApiKeyServiceTest.php` + - Cover: authenticate(), IP validation, rate limit tracking + - Priority: Complex auth logic with security implications + +- [ ] **TEST-003: Add IpRestrictionService tests** + - Create `tests/Feature/IpRestrictionServiceTest.php` + - Cover: IPv4/IPv6 validation, CIDR matching, edge cases + - Priority: Security-critical IP whitelisting logic + +- [ ] **TEST-004: Add PlanTemplateService tests** + - Create `tests/Feature/PlanTemplateServiceTest.php` + - Cover: template loading, variable substitution, plan creation + - Priority: Variable injection could create security issues + +- [ ] **TEST-005: Add AI provider service tests** + - Create `tests/Unit/ClaudeServiceTest.php` + - Create `tests/Unit/GeminiServiceTest.php` + - Create `tests/Unit/OpenAIServiceTest.php` + - Use mocked HTTP responses + +### Missing Database Infrastructure + +- [ ] **DB-001: Missing agent_plans table migration** + - Location: Migration file references `agent_plans` but no creation migration exists + - Only `agent_api_keys` and IP whitelist migrations present + - Fix: Create complete migration for all agentic tables + - Verify: `agent_plans`, `agent_phases`, `agent_sessions`, `workspace_states` + +- [ ] **DB-002: Missing indexes on frequently queried columns** + - `agent_sessions.session_id` - frequently looked up by string + - `agent_plans.slug` - used in URL routing + - `workspace_states.key` - key lookup is common operation + +### Error Handling + +- [ ] **ERR-001: ClaudeService stream() lacks error handling** + - Location: `Services/ClaudeService.php::stream()` + - Issue: No try/catch around streaming, could fail silently + - Fix: Wrap in exception handling, yield error events + +- [ ] **ERR-002: ContentService has no batch failure recovery** + - Location: `Services/ContentService.php::generateBatch()` + - Issue: Failed articles stop processing, no resume capability + - Fix: Add progress tracking, allow resuming from failed point + +--- + +## P3 - Medium Priority + +### Developer Experience + +- [ ] **DX-001: Missing workspace context error messages unclear** + - Location: Multiple MCP tools + - Issue: "workspace_id is required" doesn't explain how to fix + - Fix: Include context about authentication/session setup + +- [ ] **DX-002: AgenticManager doesn't validate API keys on init** + - Location: `Services/AgenticManager.php::registerProviders()` + - Issue: Empty API key creates provider that fails on first use + - Fix: Log warning or throw if provider configured without key + +- [ ] **DX-003: Plan template variable errors not actionable** + - Location: `Services/PlanTemplateService.php::validateVariables()` + - Fix: Include expected format, examples in error messages + +### Code Quality + +- [ ] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)** + - Files: `Models/WorkspaceState.php`, `Models/AgentWorkspaceState.php` + - Issue: Two similar models for same purpose + - Fix: Consolidate into single model, or clarify distinct purposes + +- [ ] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** + - Location: `View/Modal/Admin/ApiKeyManager.php` + - Issue: Livewire component uses different API key model than services + - Fix: Unify on AgentApiKey or document distinction + +- [ ] **CQ-003: ForAgentsController cache key not namespaced** + - Location: `Controllers/ForAgentsController.php` + - Issue: `Cache::remember('agentic.for-agents.json', ...)` could collide + - Fix: Add workspace prefix or use config-based key + +### Performance + +- [ ] **PERF-001: AgentPhase::checkDependencies does N queries** + - Location: `Models/AgentPhase.php::checkDependencies()` + - Issue: Loops through dependencies with individual `find()` calls + - Fix: Eager load or use whereIn for batch lookup + +- [ ] **PERF-002: AgentToolRegistry::forApiKey iterates all tools** + - Location: `Services/AgentToolRegistry.php::forApiKey()` + - Issue: O(n) filter on every request + - Fix: Cache permitted tools per API key + +--- + +## P4 - Low Priority + +### Documentation Gaps + +- [ ] **DOC-001: Add PHPDoc to AgentDetection patterns** + - Location: `Services/AgentDetection.php` + - Issue: User-Agent patterns undocumented + - Fix: Document each pattern with agent examples + +- [ ] **DOC-002: Document MCP tool dependency system** + - Location: `Mcp/Tools/Agent/` directory + - Fix: Add README explaining ToolDependency, context requirements + +### Feature Completion + +- [ ] **FEAT-001: Session cleanup for stale sessions** + - Issue: No mechanism to clean up abandoned sessions + - Fix: Add scheduled command to mark stale sessions as failed + - Criteria: Sessions inactive >24h marked as abandoned + +- [ ] **FEAT-002: Plan archival with data retention policy** + - Issue: Archived plans kept forever + - Fix: Add configurable retention period, cleanup job + +- [ ] **FEAT-003: Template version management** + - Location: `Services/PlanTemplateService.php` + - Issue: Template changes affect existing plan references + - Fix: Add version tracking to templates + +### Consistency + +- [ ] **CON-001: Mixed UK/US spelling in code comments** + - Issue: Some comments use "organize" instead of "organise" + - Fix: Audit and fix to UK English per CLAUDE.md + +- [ ] **CON-002: Inconsistent error response format** + - Issue: Some tools return `['error' => ...]`, others `['success' => false, ...]` + - Fix: Standardise on single error response format + +--- + +## P5 - Nice to Have + +### Observability + +- [ ] **OBS-001: Add structured logging to AI provider calls** + - Issue: No visibility into API call timing, token usage + - Fix: Add Log::info with provider, model, tokens, latency + +- [ ] **OBS-002: Add Prometheus metrics for tool execution** + - Fix: Emit tool_execution_seconds, tool_errors_total + +### Admin UI Improvements + +- [ ] **UI-001: Add bulk operations to plan list** + - Fix: Multi-select archive, activate actions + +- [ ] **UI-002: Add session timeline visualisation** + - Fix: Visual work_log display with timestamps + +- [ ] **UI-003: Add template preview before creation** + - Fix: Show resolved variables, phase list + +--- + +## P6 - Future / Backlog + +### Architecture Evolution + +- [ ] **ARCH-001: Consider event sourcing for session work_log** + - Benefit: Full replay capability, audit trail + - Consideration: Adds complexity + +- [ ] **ARCH-002: Extract AI provider abstraction to separate package** + - Benefit: Reusable across other modules + - Consideration: Increases package count + +### Integration + +- [ ] **INT-001: Add webhook notifications for plan status changes** + - Use: External integrations can react to agent progress + +- [ ] **INT-002: Add Slack/Discord integration for session alerts** + - Use: Team visibility into agent operations + +--- + +## Completed Items + +### Security (Fixed) + +- [x] Missing `agent_api_keys` table migration - Migration added +- [x] Rate limiting bypass - getRecentCallCount now reads from cache +- [x] Admin routes lack middleware - RequireHades applied +- [x] ForAgentsController missing rate limiting - Added +- [x] SEC-002: SQL injection in orderByRaw - Replaced with parameterised scopes (2026-01-29) +- [x] SEC-003: StateSet/StateGet/StateList/PlanGet/PlanList workspace scoping - Added forWorkspace() checks (2026-01-29) + +### Code Quality (Fixed) + +- [x] Add retry logic to AI provider services - HasRetry trait added +- [x] Stream parsing fragile - HasStreamParsing trait added +- [x] ContentService hardcoded paths - Now configurable +- [x] Rate limit TTL race condition - Uses Cache::add() +- [x] JSON escaping in template substitution - Added + +### DX (Fixed) + +- [x] MCP tool handlers commented out - Documented properly +- [x] MCP token lookup not implemented - Database lookup added + +--- + +## Notes + +**Test Coverage Estimate:** ~35% +- Models: Well tested (AgentPlan, AgentPhase, AgentSession) +- Services: Untested (11 services with 0% coverage) +- Commands: Untested (3 commands) +- Livewire: Untested + +**Priority Guide:** +- P1: Security/data integrity - fix before production +- P2: High impact on reliability - fix in next sprint +- P3: Developer friction - address during regular work +- P4: Nice to have - backlog candidates +- P5: Polish - when time permits +- P6: Future considerations - parking lot diff --git a/docs/api-keys.md b/docs/api-keys.md new file mode 100644 index 0000000..cb96e57 --- /dev/null +++ b/docs/api-keys.md @@ -0,0 +1,319 @@ +--- +title: API Keys +description: Guide to Agent API key management +updated: 2026-01-29 +--- + +# API Key Management + +Agent API keys provide authenticated access to the MCP tools and agentic services. This guide covers key creation, permissions, and security. + +## Key Structure + +API keys follow the format: `ak_` + 32 random alphanumeric characters. + +Example: `ak_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` + +The key is only displayed once at creation. Store it securely. + +## Creating Keys + +### Via Admin Panel + +1. Navigate to Workspace Settings > API Keys +2. Click "Create New Key" +3. Enter a descriptive name +4. Select permissions +5. Set expiration (optional) +6. Click Create +7. Copy the displayed key immediately + +### Programmatically + +```php +use Core\Mod\Agentic\Services\AgentApiKeyService; + +$service = app(AgentApiKeyService::class); + +$key = $service->create( + workspace: $workspace, + name: 'My Agent Key', + permissions: [ + AgentApiKey::PERM_PLANS_READ, + AgentApiKey::PERM_PLANS_WRITE, + AgentApiKey::PERM_SESSIONS_WRITE, + ], + rateLimit: 100, + expiresAt: now()->addYear() +); + +// Only available once +$plainKey = $key->plainTextKey; +``` + +## Permissions + +### Available Permissions + +| Permission | Constant | Description | +|------------|----------|-------------| +| `plans.read` | `PERM_PLANS_READ` | List and view plans | +| `plans.write` | `PERM_PLANS_WRITE` | Create, update, archive plans | +| `phases.write` | `PERM_PHASES_WRITE` | Update phases, manage tasks | +| `sessions.read` | `PERM_SESSIONS_READ` | List and view sessions | +| `sessions.write` | `PERM_SESSIONS_WRITE` | Start, update, end sessions | +| `tools.read` | `PERM_TOOLS_READ` | View tool analytics | +| `templates.read` | `PERM_TEMPLATES_READ` | List and view templates | +| `templates.instantiate` | `PERM_TEMPLATES_INSTANTIATE` | Create plans from templates | +| `notify:read` | `PERM_NOTIFY_READ` | List push campaigns | +| `notify:write` | `PERM_NOTIFY_WRITE` | Create/update campaigns | +| `notify:send` | `PERM_NOTIFY_SEND` | Send notifications | + +### Permission Checking + +```php +// Single permission +$key->hasPermission('plans.write'); + +// Any of several +$key->hasAnyPermission(['plans.read', 'sessions.read']); + +// All required +$key->hasAllPermissions(['plans.write', 'phases.write']); +``` + +### Updating Permissions + +```php +$service->updatePermissions($key, [ + AgentApiKey::PERM_PLANS_READ, + AgentApiKey::PERM_SESSIONS_READ, +]); +``` + +## Rate Limiting + +### Configuration + +Each key has a configurable rate limit (requests per minute): + +```php +$key = $service->create( + workspace: $workspace, + name: 'Limited Key', + permissions: [...], + rateLimit: 50 // 50 requests/minute +); + +// Update later +$service->updateRateLimit($key, 100); +``` + +### Checking Status + +```php +$status = $service->getRateLimitStatus($key); +// Returns: +// [ +// 'limit' => 100, +// 'remaining' => 85, +// 'reset_in_seconds' => 45, +// 'used' => 15 +// ] +``` + +### Response Headers + +Rate limit info is included in API responses: + +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 85 +X-RateLimit-Reset: 45 +``` + +When rate limited (HTTP 429): +``` +Retry-After: 45 +``` + +## IP Restrictions + +Keys can be restricted to specific IP addresses or ranges. + +### Enabling Restrictions + +```php +// Enable with whitelist +$service->enableIpRestrictions($key, [ + '192.168.1.0/24', // CIDR range + '10.0.0.5', // Single IPv4 + '2001:db8::1', // Single IPv6 + '2001:db8::/32', // IPv6 CIDR +]); + +// Disable restrictions +$service->disableIpRestrictions($key); +``` + +### Managing Whitelist + +```php +// Add single entry +$key->addToIpWhitelist('192.168.2.0/24'); + +// Remove entry +$key->removeFromIpWhitelist('192.168.1.0/24'); + +// Replace entire list +$key->updateIpWhitelist([ + '10.0.0.0/8', + '172.16.0.0/12', +]); +``` + +### Parsing Input + +For user-entered whitelists: + +```php +$result = $service->parseIpWhitelistInput(" + 192.168.1.1 + 192.168.2.0/24 + # This is a comment + invalid-ip +"); + +// Result: +// [ +// 'entries' => ['192.168.1.1', '192.168.2.0/24'], +// 'errors' => ['invalid-ip: Invalid IP address'] +// ] +``` + +## Key Lifecycle + +### Expiration + +```php +// Set expiration on create +$key = $service->create( + ... + expiresAt: now()->addMonths(6) +); + +// Extend expiration +$service->extendExpiry($key, now()->addYear()); + +// Remove expiration (never expires) +$service->removeExpiry($key); +``` + +### Revocation + +```php +// Immediately revoke +$service->revoke($key); + +// Check status +$key->isRevoked(); // true +$key->isActive(); // false +``` + +### Status Helpers + +```php +$key->isActive(); // Not revoked, not expired +$key->isRevoked(); // Has been revoked +$key->isExpired(); // Past expiration date +$key->getStatusLabel(); // "Active", "Revoked", or "Expired" +``` + +## Authentication + +### Making Requests + +Include the API key as a Bearer token: + +```bash +curl -H "Authorization: Bearer ak_your_key_here" \ + https://mcp.host.uk.com/api/agent/plans +``` + +### Authentication Flow + +1. Middleware extracts Bearer token +2. Key looked up by SHA-256 hash +3. Status checked (revoked, expired) +4. IP validated if restrictions enabled +5. Permissions checked against required scopes +6. Rate limit checked and incremented +7. Usage recorded (count, timestamp, IP) + +### Error Responses + +| HTTP Code | Error | Description | +|-----------|-------|-------------| +| 401 | `unauthorised` | Missing or invalid key | +| 401 | `key_revoked` | Key has been revoked | +| 401 | `key_expired` | Key has expired | +| 403 | `ip_not_allowed` | Request IP not whitelisted | +| 403 | `permission_denied` | Missing required permission | +| 429 | `rate_limited` | Rate limit exceeded | + +## Usage Tracking + +Each key tracks: +- `call_count` - Total lifetime calls +- `last_used_at` - Timestamp of last use +- `last_used_ip` - IP of last request + +Access via: +```php +$key->call_count; +$key->getLastUsedForHumans(); // "2 hours ago" +``` + +## Best Practices + +1. **Use descriptive names** - "Production Agent" not "Key 1" +2. **Minimal permissions** - Only grant needed scopes +3. **Set expiration** - Rotate keys periodically +4. **Enable IP restrictions** - When agents run from known IPs +5. **Monitor usage** - Review call patterns regularly +6. **Revoke promptly** - If key may be compromised +7. **Separate environments** - Different keys for dev/staging/prod + +## Example: Complete Setup + +```php +use Core\Mod\Agentic\Services\AgentApiKeyService; +use Core\Mod\Agentic\Models\AgentApiKey; + +$service = app(AgentApiKeyService::class); + +// Create a production key +$key = $service->create( + workspace: $workspace, + name: 'Production Agent - Claude', + permissions: [ + AgentApiKey::PERM_PLANS_READ, + AgentApiKey::PERM_PLANS_WRITE, + AgentApiKey::PERM_PHASES_WRITE, + AgentApiKey::PERM_SESSIONS_WRITE, + AgentApiKey::PERM_TEMPLATES_READ, + AgentApiKey::PERM_TEMPLATES_INSTANTIATE, + ], + rateLimit: 200, + expiresAt: now()->addYear() +); + +// Restrict to known IPs +$service->enableIpRestrictions($key, [ + '203.0.113.0/24', // Office network + '198.51.100.50', // CI/CD server +]); + +// Store the key securely +$plainKey = $key->plainTextKey; // Only chance to get this! +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e393fed --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,322 @@ +--- +title: Architecture +description: Technical architecture of the core-agentic package +updated: 2026-01-29 +--- + +# Architecture + +The `core-agentic` package provides AI agent orchestration infrastructure for the Host UK platform. It enables multi-agent collaboration, persistent task tracking, and unified access to multiple AI providers. + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MCP Protocol Layer │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Plan │ │ Phase │ │ Session │ │ State │ ... tools │ +│ │ Tools │ │ Tools │ │ Tools │ │ Tools │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +└───────┼────────────┼────────────┼────────────┼──────────────────┘ + │ │ │ │ +┌───────┴────────────┴────────────┴────────────┴──────────────────┐ +│ AgentToolRegistry │ +│ - Tool registration and discovery │ +│ - Permission checking (API key scopes) │ +│ - Dependency validation │ +│ - Circuit breaker integration │ +└──────────────────────────────────────────────────────────────────┘ + │ +┌───────┴──────────────────────────────────────────────────────────┐ +│ Core Services │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ AgenticManager │ │ AgentApiKey │ │ PlanTemplate │ │ +│ │ (AI Providers) │ │ Service │ │ Service │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ IpRestriction │ │ Content │ │ AgentSession │ │ +│ │ Service │ │ Service │ │ Service │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ +┌───────┴──────────────────────────────────────────────────────────┐ +│ Data Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐│ +│ │ AgentPlan │ │ AgentPhase │ │ AgentSession│ │ AgentApiKey ││ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘│ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ Workspace │ │ Task │ │ +│ │ State │ │ │ │ +│ └─────────────┘ └─────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Core Concepts + +### Agent Plans + +Plans represent structured work with phases, tasks, and progress tracking. They persist across agent sessions, enabling handoff between different AI models or instances. + +``` +AgentPlan +├── slug (unique identifier) +├── title +├── status (draft → active → completed/archived) +├── current_phase +└── phases[] (AgentPhase) + ├── name + ├── tasks[] + │ ├── name + │ └── status + ├── dependencies[] + └── checkpoints[] +``` + +**Lifecycle:** +1. Created via MCP tool or template +2. Activated to begin work +3. Phases started/completed in order +4. Plan auto-completes when all phases done +5. Archived for historical reference + +### Agent Sessions + +Sessions track individual work periods. They enable context recovery when an agent's context window resets or when handing off to another agent. + +``` +AgentSession +├── session_id (prefixed unique ID) +├── agent_type (opus/sonnet/haiku) +├── status (active/paused/completed/failed) +├── work_log[] (chronological actions) +├── artifacts[] (files created/modified) +├── context_summary (current state) +└── handoff_notes (for next agent) +``` + +**Handoff Flow:** +1. Session logs work as it progresses +2. Before context ends, agent calls `session_handoff` +3. Handoff notes capture summary, next steps, blockers +4. Next agent calls `session_resume` to continue +5. Resume session inherits context from previous + +### Workspace State + +Key-value state storage shared between sessions and plans. Enables agents to persist decisions, configurations, and intermediate results. + +``` +WorkspaceState +├── key (namespaced identifier) +├── value (any JSON-serialisable data) +├── type (json/markdown/code/reference) +└── category (for organisation) +``` + +## MCP Tool Architecture + +All MCP tools extend the `AgentTool` base class which provides: + +### Input Validation + +```php +protected function requireString(array $args, string $key, ?int $maxLength = null): string +protected function optionalInt(array $args, string $key, ?int $default = null): ?int +protected function requireEnum(array $args, string $key, array $allowed): string +``` + +### Circuit Breaker Protection + +```php +return $this->withCircuitBreaker('agentic', function () { + // Database operations that could fail + return AgentPlan::where('slug', $slug)->first(); +}, fn () => $this->error('Service unavailable', 'circuit_open')); +``` + +### Dependency Declaration + +```php +public function dependencies(): array +{ + return [ + ToolDependency::contextExists('workspace_id', 'Workspace required'), + ToolDependency::toolCalled('session_start', 'Start session first'), + ]; +} +``` + +### Tool Categories + +| Category | Tools | Purpose | +|----------|-------|---------| +| `plan` | plan_create, plan_get, plan_list, plan_update_status, plan_archive | Work plan management | +| `phase` | phase_get, phase_update_status, phase_add_checkpoint | Phase operations | +| `session` | session_start, session_end, session_log, session_handoff, session_resume, session_replay | Session tracking | +| `state` | state_get, state_set, state_list | Persistent state | +| `task` | task_update, task_toggle | Task completion | +| `template` | template_list, template_preview, template_create_plan | Plan templates | +| `content` | content_generate, content_batch_generate, content_brief_create | Content generation | + +## AI Provider Abstraction + +The `AgenticManager` provides unified access to multiple AI providers: + +```php +$ai = app(AgenticManager::class); + +// Use specific provider +$response = $ai->claude()->generate($system, $user); +$response = $ai->gemini()->generate($system, $user); +$response = $ai->openai()->generate($system, $user); + +// Use by name (for configuration-driven selection) +$response = $ai->provider('gemini')->generate($system, $user); +``` + +### Provider Interface + +All providers implement `AgenticProviderInterface`: + +```php +interface AgenticProviderInterface +{ + public function generate(string $systemPrompt, string $userPrompt, array $config = []): AgenticResponse; + public function stream(string $systemPrompt, string $userPrompt, array $config = []): Generator; + public function name(): string; + public function defaultModel(): string; + public function isAvailable(): bool; +} +``` + +### Response Object + +```php +class AgenticResponse +{ + public string $content; + public string $model; + public int $inputTokens; + public int $outputTokens; + public int $durationMs; + public ?string $stopReason; + public array $raw; + + public function estimateCost(): float; +} +``` + +## Authentication + +### API Key Flow + +``` +Request → AgentApiAuth Middleware → AgentApiKeyService::authenticate() + │ + ├── Validate key (SHA-256 hash lookup) + ├── Check revoked/expired + ├── Validate IP whitelist + ├── Check permissions + ├── Check rate limit + └── Record usage +``` + +### Permission Model + +```php +// Permission constants +AgentApiKey::PERM_PLANS_READ // 'plans.read' +AgentApiKey::PERM_PLANS_WRITE // 'plans.write' +AgentApiKey::PERM_SESSIONS_WRITE // 'sessions.write' +// etc. + +// Check permissions +$key->hasPermission('plans.write'); +$key->hasAllPermissions(['plans.read', 'sessions.write']); +``` + +### IP Restrictions + +API keys can optionally restrict access by IP: + +- Individual IPv4/IPv6 addresses +- CIDR notation (e.g., `192.168.1.0/24`) +- Mixed whitelist + +## Event-Driven Boot + +The module uses the Core framework's event-driven lazy loading: + +```php +class Boot extends ServiceProvider +{ + public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + ConsoleBooting::class => 'onConsole', + McpToolsRegistering::class => 'onMcpTools', + ]; +} +``` + +This ensures: +- Views only loaded when admin panel boots +- Commands only registered when console boots +- MCP tools only registered when MCP module initialises + +## Multi-Tenancy + +All data is workspace-scoped via the `BelongsToWorkspace` trait: + +- Queries auto-scoped to current workspace +- Creates auto-assign workspace_id +- Cross-tenant queries throw `MissingWorkspaceContextException` + +## File Structure + +``` +core-agentic/ +├── Boot.php # Service provider with event handlers +├── config.php # Module configuration +├── Migrations/ # Database schema +├── Models/ # Eloquent models +│ ├── AgentPlan.php +│ ├── AgentPhase.php +│ ├── AgentSession.php +│ ├── AgentApiKey.php +│ └── WorkspaceState.php +├── Services/ # Business logic +│ ├── AgenticManager.php # AI provider orchestration +│ ├── AgentApiKeyService.php # API key management +│ ├── IpRestrictionService.php +│ ├── PlanTemplateService.php +│ ├── ContentService.php +│ ├── ClaudeService.php +│ ├── GeminiService.php +│ └── OpenAIService.php +├── Mcp/ +│ ├── Tools/Agent/ # MCP tool implementations +│ │ ├── AgentTool.php # Base class +│ │ ├── Plan/ +│ │ ├── Phase/ +│ │ ├── Session/ +│ │ ├── State/ +│ │ └── ... +│ ├── Prompts/ # MCP prompt definitions +│ └── Servers/ # MCP server configurations +├── Middleware/ +│ └── AgentApiAuth.php # API authentication +├── Controllers/ +│ └── ForAgentsController.php # Agent discovery endpoint +├── View/ +│ ├── Blade/admin/ # Admin panel views +│ └── Modal/Admin/ # Livewire components +├── Jobs/ # Queue jobs +├── Console/Commands/ # Artisan commands +└── Tests/ # Pest test suites +``` + +## Dependencies + +- `host-uk/core` - Event system, base classes +- `host-uk/core-tenant` - Workspace, BelongsToWorkspace trait +- `host-uk/core-mcp` - MCP infrastructure, CircuitBreaker diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md new file mode 100644 index 0000000..da12266 --- /dev/null +++ b/docs/mcp-tools.md @@ -0,0 +1,670 @@ +--- +title: MCP Tools Reference +description: Complete reference for core-agentic MCP tools +updated: 2026-01-29 +--- + +# MCP Tools Reference + +This document provides a complete reference for all MCP tools in the `core-agentic` package. + +## Overview + +Tools are organised into categories: + +| Category | Description | Tools Count | +|----------|-------------|-------------| +| plan | Work plan management | 5 | +| phase | Phase operations | 3 | +| session | Session tracking | 8 | +| state | Persistent state | 3 | +| task | Task completion | 2 | +| template | Plan templates | 3 | +| content | Content generation | 6 | + +## Plan Tools + +### plan_create + +Create a new work plan with phases and tasks. + +**Scopes:** `write` + +**Input:** +```json +{ + "title": "string (required)", + "slug": "string (optional, auto-generated)", + "description": "string (optional)", + "context": "object (optional)", + "phases": [ + { + "name": "string", + "description": "string", + "tasks": ["string"] + } + ] +} +``` + +**Output:** +```json +{ + "success": true, + "plan": { + "slug": "my-plan-abc123", + "title": "My Plan", + "status": "draft", + "phases": 3 + } +} +``` + +**Dependencies:** workspace_id in context + +--- + +### plan_get + +Get a plan by slug with full details. + +**Scopes:** `read` + +**Input:** +```json +{ + "slug": "string (required)" +} +``` + +**Output:** +```json +{ + "success": true, + "plan": { + "slug": "my-plan", + "title": "My Plan", + "status": "active", + "progress": { + "total": 5, + "completed": 2, + "percentage": 40 + }, + "phases": [...] + } +} +``` + +--- + +### plan_list + +List plans with optional filtering. + +**Scopes:** `read` + +**Input:** +```json +{ + "status": "string (optional: draft|active|completed|archived)", + "limit": "integer (optional, default 20)" +} +``` + +**Output:** +```json +{ + "success": true, + "plans": [ + { + "slug": "plan-1", + "title": "Plan One", + "status": "active" + } + ], + "count": 1 +} +``` + +--- + +### plan_update_status + +Update a plan's status. + +**Scopes:** `write` + +**Input:** +```json +{ + "slug": "string (required)", + "status": "string (required: draft|active|completed|archived)" +} +``` + +--- + +### plan_archive + +Archive a plan with optional reason. + +**Scopes:** `write` + +**Input:** +```json +{ + "slug": "string (required)", + "reason": "string (optional)" +} +``` + +## Phase Tools + +### phase_get + +Get phase details by plan slug and phase order. + +**Scopes:** `read` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "phase_order": "integer (required)" +} +``` + +--- + +### phase_update_status + +Update a phase's status. + +**Scopes:** `write` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "phase_order": "integer (required)", + "status": "string (required: pending|in_progress|completed|blocked|skipped)", + "reason": "string (optional, for blocked/skipped)" +} +``` + +--- + +### phase_add_checkpoint + +Add a checkpoint note to a phase. + +**Scopes:** `write` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "phase_order": "integer (required)", + "note": "string (required)", + "context": "object (optional)" +} +``` + +## Session Tools + +### session_start + +Start a new agent session. + +**Scopes:** `write` + +**Input:** +```json +{ + "plan_slug": "string (optional)", + "agent_type": "string (required: opus|sonnet|haiku)", + "context": "object (optional)" +} +``` + +**Output:** +```json +{ + "success": true, + "session": { + "session_id": "ses_abc123xyz", + "agent_type": "opus", + "plan": "my-plan", + "status": "active" + } +} +``` + +--- + +### session_end + +End a session with status and summary. + +**Scopes:** `write` + +**Input:** +```json +{ + "session_id": "string (required)", + "status": "string (required: completed|failed)", + "summary": "string (optional)" +} +``` + +--- + +### session_log + +Add a work log entry to an active session. + +**Scopes:** `write` + +**Input:** +```json +{ + "session_id": "string (required)", + "message": "string (required)", + "type": "string (optional: info|warning|error|success|checkpoint)", + "data": "object (optional)" +} +``` + +--- + +### session_handoff + +Prepare session for handoff to another agent. + +**Scopes:** `write` + +**Input:** +```json +{ + "session_id": "string (required)", + "summary": "string (required)", + "next_steps": ["string"], + "blockers": ["string"], + "context_for_next": "object (optional)" +} +``` + +--- + +### session_resume + +Resume a paused session. + +**Scopes:** `write` + +**Input:** +```json +{ + "session_id": "string (required)" +} +``` + +**Output:** +```json +{ + "success": true, + "session": {...}, + "handoff_context": { + "summary": "Previous work summary", + "next_steps": ["Continue with..."], + "blockers": [] + } +} +``` + +--- + +### session_replay + +Get replay context for a session. + +**Scopes:** `read` + +**Input:** +```json +{ + "session_id": "string (required)" +} +``` + +**Output:** +```json +{ + "success": true, + "replay_context": { + "session_id": "ses_abc123", + "progress_summary": {...}, + "last_checkpoint": {...}, + "decisions": [...], + "errors": [...] + } +} +``` + +--- + +### session_continue + +Create a new session that continues from a previous one. + +**Scopes:** `write` + +**Input:** +```json +{ + "session_id": "string (required)", + "agent_type": "string (optional)" +} +``` + +--- + +### session_artifact + +Add an artifact (file) to a session. + +**Scopes:** `write` + +**Input:** +```json +{ + "session_id": "string (required)", + "path": "string (required)", + "action": "string (required: created|modified|deleted)", + "metadata": "object (optional)" +} +``` + +--- + +### session_list + +List sessions with optional filtering. + +**Scopes:** `read` + +**Input:** +```json +{ + "plan_slug": "string (optional)", + "status": "string (optional)", + "limit": "integer (optional)" +} +``` + +## State Tools + +### state_set + +Set a workspace state value. + +**Scopes:** `write` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "key": "string (required)", + "value": "any (required)", + "category": "string (optional)" +} +``` + +--- + +### state_get + +Get a workspace state value. + +**Scopes:** `read` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "key": "string (required)" +} +``` + +--- + +### state_list + +List all state for a plan. + +**Scopes:** `read` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "category": "string (optional)" +} +``` + +## Task Tools + +### task_update + +Update a task within a phase. + +**Scopes:** `write` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "phase_order": "integer (required)", + "task_identifier": "string|integer (required)", + "status": "string (optional: pending|completed)", + "notes": "string (optional)" +} +``` + +--- + +### task_toggle + +Toggle a task's completion status. + +**Scopes:** `write` + +**Input:** +```json +{ + "plan_slug": "string (required)", + "phase_order": "integer (required)", + "task_identifier": "string|integer (required)" +} +``` + +## Template Tools + +### template_list + +List available plan templates. + +**Scopes:** `read` + +**Output:** +```json +{ + "success": true, + "templates": [ + { + "slug": "feature-development", + "name": "Feature Development", + "description": "Standard feature workflow", + "phases_count": 5, + "variables": [ + { + "name": "FEATURE_NAME", + "required": true + } + ] + } + ] +} +``` + +--- + +### template_preview + +Preview a template with variable substitution. + +**Scopes:** `read` + +**Input:** +```json +{ + "slug": "string (required)", + "variables": { + "FEATURE_NAME": "Authentication" + } +} +``` + +--- + +### template_create_plan + +Create a plan from a template. + +**Scopes:** `write` + +**Input:** +```json +{ + "template_slug": "string (required)", + "variables": "object (required)", + "title": "string (optional, overrides template)", + "activate": "boolean (optional, default false)" +} +``` + +## Content Tools + +### content_generate + +Generate content using AI. + +**Scopes:** `write` + +**Input:** +```json +{ + "prompt": "string (required)", + "provider": "string (optional: claude|gemini|openai)", + "config": { + "temperature": 0.7, + "max_tokens": 4000 + } +} +``` + +--- + +### content_batch_generate + +Generate content for a batch specification. + +**Scopes:** `write` + +**Input:** +```json +{ + "batch_id": "string (required)", + "provider": "string (optional)", + "dry_run": "boolean (optional)" +} +``` + +--- + +### content_brief_create + +Create a content brief for later generation. + +**Scopes:** `write` + +--- + +### content_brief_get + +Get a content brief. + +**Scopes:** `read` + +--- + +### content_brief_list + +List content briefs. + +**Scopes:** `read` + +--- + +### content_status + +Get batch generation status. + +**Scopes:** `read` + +--- + +### content_usage_stats + +Get AI usage statistics. + +**Scopes:** `read` + +--- + +### content_from_plan + +Generate content based on plan context. + +**Scopes:** `write` + +## Error Responses + +All tools return errors in this format: + +```json +{ + "error": "Error message", + "code": "error_code" +} +``` + +Common error codes: +- `validation_error` - Invalid input +- `not_found` - Resource not found +- `permission_denied` - Insufficient permissions +- `rate_limited` - Rate limit exceeded +- `service_unavailable` - Circuit breaker open + +## Circuit Breaker + +Tools use circuit breaker protection for database calls. When the circuit opens: + +```json +{ + "error": "Agentic service temporarily unavailable", + "code": "service_unavailable" +} +``` + +The circuit resets after successful health checks. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..d5bf2ef --- /dev/null +++ b/docs/security.md @@ -0,0 +1,279 @@ +--- +title: Security +description: Security considerations and audit notes for core-agentic +updated: 2026-01-29 +--- + +# Security Considerations + +This document outlines security considerations, known issues, and recommendations for the `core-agentic` package. + +## Authentication + +### API Key Security + +**Current Implementation:** +- Keys generated with `ak_` prefix + 32 random characters +- Stored as SHA-256 hash (no salt) +- Key only visible once at creation time +- Supports expiration dates +- Supports revocation + +**Known Issues:** + +1. **No salt in hash (SEC-001)** + - Risk: Rainbow table attacks possible against common key formats + - Mitigation: Keys are high-entropy (32 random chars), reducing practical risk + - Recommendation: Migrate to Argon2id with salt + +2. **Key prefix visible in hash display** + - The `getMaskedKey()` method shows first 6 chars of the hash, not the original key + - This is safe but potentially confusing for users + +**Recommendations:** +- Consider key rotation reminders +- Add key compromise detection (unusual usage patterns) +- Implement key versioning for smooth rotation + +### IP Whitelisting + +**Implementation:** +- Per-key IP restriction toggle +- Supports IPv4 and IPv6 +- Supports CIDR notation +- Logged when requests blocked + +**Validation:** +- Uses `filter_var()` with `FILTER_VALIDATE_IP` +- CIDR prefix validated against IP version limits (0-32 for IPv4, 0-128 for IPv6) +- Normalises IPs for consistent comparison + +**Edge Cases Handled:** +- Empty whitelist with restrictions enabled = deny all +- Invalid IPs/CIDRs rejected during configuration +- IP version mismatch (IPv4 vs IPv6) handled correctly + +## Authorisation + +### Multi-Tenancy + +**Workspace Scoping:** +- All models use `BelongsToWorkspace` trait +- Queries automatically scoped to current workspace context +- Missing workspace throws `MissingWorkspaceContextException` + +**Known Issues:** + +1. **StateSet tool lacks workspace validation (SEC-003)** + - Risk: Plan lookup by slug without workspace constraint + - Impact: Could allow cross-tenant state manipulation if slugs collide + - Fix: Add workspace_id check to plan query + +2. **Some tools have soft dependency on workspace** + - SessionStart marks workspace as optional if plan_slug provided + - Could theoretically allow workspace inference attacks + +### Permission Model + +**Scopes:** +- `plans.read` - List and view plans +- `plans.write` - Create, update, archive plans +- `phases.write` - Update phase status, manage tasks +- `sessions.read` - List and view sessions +- `sessions.write` - Start, update, complete sessions +- `tools.read` - View tool analytics +- `templates.read` - List and view templates +- `templates.instantiate` - Create plans from templates + +**Tool Scope Enforcement:** +- Each tool declares required scopes +- `AgentToolRegistry::execute()` validates scopes before execution +- Missing scope throws `RuntimeException` + +## Rate Limiting + +### Current Implementation + +**Global Rate Limiting:** +- ForAgentsController: 60 requests/minute per IP +- Configured via `RateLimiter::for('agentic-api')` + +**Per-Key Rate Limiting:** +- Configurable per API key (default: 100/minute) +- Uses cache-based counter with 60-second TTL +- Atomic increment via `Cache::add()` + `Cache::increment()` + +**Known Issues:** + +1. **No per-tool rate limiting (SEC-004)** + - Risk: Single key can call expensive tools unlimited times + - Impact: Resource exhaustion, cost overrun + - Fix: Add tool-specific rate limits + +2. **Rate limit counter not distributed** + - Multiple app servers may have separate counters + - Fix: Ensure Redis cache driver in production + +### Response Headers + +Rate limit status exposed via headers: +- `X-RateLimit-Limit` - Maximum requests allowed +- `X-RateLimit-Remaining` - Requests remaining in window +- `X-RateLimit-Reset` - Seconds until reset +- `Retry-After` - When rate limited + +## Input Validation + +### MCP Tool Inputs + +**Validation Helpers:** +- `requireString()` - Type + optional length validation +- `requireInt()` - Type + optional min/max validation +- `requireEnum()` - Value from allowed set +- `requireArray()` - Type validation + +**Known Issues:** + +1. **Template variable injection (VAL-001)** + - JSON escaping added but character validation missing + - Risk: Specially crafted variables could affect template behaviour + - Recommendation: Add explicit character whitelist + +2. **SQL orderByRaw pattern (SEC-002)** + - TaskCommand uses raw SQL for FIELD() ordering + - Currently safe (hardcoded values) but fragile pattern + - Recommendation: Use parameterised approach + +### Content Validation + +ContentService validates generated content: +- Minimum word count (600 words) +- UK English spelling checks +- Banned word detection +- Structure validation (headings required) + +## Data Protection + +### Sensitive Data Handling + +**API Keys:** +- Plaintext only available once (at creation) +- Hash stored, never logged +- Excluded from model serialisation via `$hidden` + +**Session Data:** +- Work logs may contain sensitive context +- Artifacts track file paths (not contents) +- Context summaries could contain user data + +**Recommendations:** +- Add data retention policies for sessions +- Consider encrypting context_summary field +- Audit work_log for sensitive data patterns + +### Logging + +**Current Logging:** +- IP restriction blocks logged with key metadata +- No API key plaintext ever logged +- No sensitive context logged + +**Recommendations:** +- Add audit logging for permission changes +- Log key creation/revocation events +- Consider structured logging for SIEM integration + +## Transport Security + +**Requirements:** +- All endpoints should be HTTPS-only +- MCP portal at mcp.host.uk.com +- API endpoints under /api/agent/* + +**Headers Set:** +- `X-Client-IP` - For debugging/audit +- Rate limit headers + +**Recommendations:** +- Add HSTS headers +- Consider mTLS for high-security deployments + +## Dependency Security + +### External API Calls + +AI provider services make external API calls: +- Anthropic API (Claude) +- Google AI API (Gemini) +- OpenAI API + +**Security Measures:** +- API keys from environment variables only +- HTTPS connections +- 300-second timeout +- Retry with exponential backoff + +**Recommendations:** +- Consider API key vault integration +- Add certificate pinning for provider endpoints +- Monitor for API key exposure in responses + +### Internal Dependencies + +The package depends on: +- `host-uk/core` - Event system +- `host-uk/core-tenant` - Workspace scoping +- `host-uk/core-mcp` - MCP infrastructure + +All are internal packages with shared security posture. + +## Audit Checklist + +### Pre-Production + +- [ ] All SEC-* issues in TODO.md addressed +- [ ] API key hashing upgraded to Argon2id +- [ ] StateSet workspace scoping fixed +- [ ] Per-tool rate limiting implemented +- [ ] Test coverage for auth/permission logic + +### Regular Audits + +- [ ] Review API key usage patterns +- [ ] Check for expired but not revoked keys +- [ ] Audit workspace scope bypass attempts +- [ ] Review rate limit effectiveness +- [ ] Check for unusual tool call patterns + +### Incident Response + +1. **Compromised API Key** + - Immediately revoke via `$key->revoke()` + - Check usage history in database + - Notify affected workspace owner + - Review all actions taken with key + +2. **Cross-Tenant Access** + - Disable affected workspace + - Audit all data access + - Review workspace scoping logic + - Implement additional checks + +## Security Contacts + +For security issues: +- Create private issue in repository +- Email security@host.uk.com +- Do not disclose publicly until patched + +## Changelog + +**2026-01-29** +- Initial security documentation +- Documented known issues SEC-001 through SEC-004 +- Added audit checklist + +**2026-01-21** +- Rate limiting functional (was stub) +- Admin routes now require Hades role +- ForAgentsController rate limited diff --git a/tests/Feature/SecurityTest.php b/tests/Feature/SecurityTest.php new file mode 100644 index 0000000..16c33a0 --- /dev/null +++ b/tests/Feature/SecurityTest.php @@ -0,0 +1,432 @@ +workspace = Workspace::factory()->create(); + $this->otherWorkspace = Workspace::factory()->create(); + } + + // ========================================================================= + // StateSet Workspace Scoping Tests + // ========================================================================= + + public function test_state_set_requires_workspace_context(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $tool = new StateSet(); + $result = $tool->handle([ + 'plan_slug' => $plan->slug, + 'key' => 'test_key', + 'value' => 'test_value', + ], []); // No workspace_id in context + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('workspace_id is required', $result['error']); + } + + public function test_state_set_cannot_access_other_workspace_plans(): void + { + $otherPlan = AgentPlan::factory()->create([ + 'workspace_id' => $this->otherWorkspace->id, + ]); + + $tool = new StateSet(); + $result = $tool->handle([ + 'plan_slug' => $otherPlan->slug, + 'key' => 'test_key', + 'value' => 'test_value', + ], ['workspace_id' => $this->workspace->id]); // Different workspace + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('Plan not found', $result['error']); + } + + public function test_state_set_works_with_correct_workspace(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $tool = new StateSet(); + $result = $tool->handle([ + 'plan_slug' => $plan->slug, + 'key' => 'test_key', + 'value' => 'test_value', + ], ['workspace_id' => $this->workspace->id]); + + $this->assertArrayHasKey('success', $result); + $this->assertTrue($result['success']); + $this->assertEquals('test_key', $result['state']['key']); + } + + // ========================================================================= + // StateGet Workspace Scoping Tests + // ========================================================================= + + public function test_state_get_requires_workspace_context(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + AgentWorkspaceState::create([ + 'agent_plan_id' => $plan->id, + 'key' => 'test_key', + 'value' => ['data' => 'secret'], + ]); + + $tool = new StateGet(); + $result = $tool->handle([ + 'plan_slug' => $plan->slug, + 'key' => 'test_key', + ], []); // No workspace_id in context + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('workspace_id is required', $result['error']); + } + + public function test_state_get_cannot_access_other_workspace_state(): void + { + $otherPlan = AgentPlan::factory()->create([ + 'workspace_id' => $this->otherWorkspace->id, + ]); + + AgentWorkspaceState::create([ + 'agent_plan_id' => $otherPlan->id, + 'key' => 'secret_key', + 'value' => ['data' => 'sensitive'], + ]); + + $tool = new StateGet(); + $result = $tool->handle([ + 'plan_slug' => $otherPlan->slug, + 'key' => 'secret_key', + ], ['workspace_id' => $this->workspace->id]); // Different workspace + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('Plan not found', $result['error']); + } + + public function test_state_get_works_with_correct_workspace(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + AgentWorkspaceState::create([ + 'agent_plan_id' => $plan->id, + 'key' => 'test_key', + 'value' => ['data' => 'allowed'], + ]); + + $tool = new StateGet(); + $result = $tool->handle([ + 'plan_slug' => $plan->slug, + 'key' => 'test_key', + ], ['workspace_id' => $this->workspace->id]); + + $this->assertArrayHasKey('success', $result); + $this->assertTrue($result['success']); + $this->assertEquals('test_key', $result['key']); + } + + // ========================================================================= + // StateList Workspace Scoping Tests + // ========================================================================= + + public function test_state_list_requires_workspace_context(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $tool = new StateList(); + $result = $tool->handle([ + 'plan_slug' => $plan->slug, + ], []); // No workspace_id in context + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('workspace_id is required', $result['error']); + } + + public function test_state_list_cannot_access_other_workspace_states(): void + { + $otherPlan = AgentPlan::factory()->create([ + 'workspace_id' => $this->otherWorkspace->id, + ]); + + AgentWorkspaceState::create([ + 'agent_plan_id' => $otherPlan->id, + 'key' => 'secret_key', + 'value' => ['data' => 'sensitive'], + ]); + + $tool = new StateList(); + $result = $tool->handle([ + 'plan_slug' => $otherPlan->slug, + ], ['workspace_id' => $this->workspace->id]); // Different workspace + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('Plan not found', $result['error']); + } + + // ========================================================================= + // PlanGet Workspace Scoping Tests + // ========================================================================= + + public function test_plan_get_requires_workspace_context(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $tool = new PlanGet(); + $result = $tool->handle([ + 'slug' => $plan->slug, + ], []); // No workspace_id in context + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('workspace_id is required', $result['error']); + } + + public function test_plan_get_cannot_access_other_workspace_plans(): void + { + $otherPlan = AgentPlan::factory()->create([ + 'workspace_id' => $this->otherWorkspace->id, + 'title' => 'Secret Plan', + ]); + + $tool = new PlanGet(); + $result = $tool->handle([ + 'slug' => $otherPlan->slug, + ], ['workspace_id' => $this->workspace->id]); // Different workspace + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('Plan not found', $result['error']); + } + + public function test_plan_get_works_with_correct_workspace(): void + { + $plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'My Plan', + ]); + + $tool = new PlanGet(); + $result = $tool->handle([ + 'slug' => $plan->slug, + ], ['workspace_id' => $this->workspace->id]); + + $this->assertArrayHasKey('success', $result); + $this->assertTrue($result['success']); + $this->assertEquals('My Plan', $result['plan']['title']); + } + + // ========================================================================= + // PlanList Workspace Scoping Tests + // ========================================================================= + + public function test_plan_list_requires_workspace_context(): void + { + $tool = new PlanList(); + $result = $tool->handle([], []); // No workspace_id in context + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('workspace_id is required', $result['error']); + } + + public function test_plan_list_only_returns_workspace_plans(): void + { + // Create plans in both workspaces + AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'My Plan', + ]); + AgentPlan::factory()->create([ + 'workspace_id' => $this->otherWorkspace->id, + 'title' => 'Other Plan', + ]); + + $tool = new PlanList(); + $result = $tool->handle([], ['workspace_id' => $this->workspace->id]); + + $this->assertArrayHasKey('success', $result); + $this->assertTrue($result['success']); + $this->assertEquals(1, $result['total']); + $this->assertEquals('My Plan', $result['plans'][0]['title']); + } + + // ========================================================================= + // Task Model Ordering Tests (SQL Injection Prevention) + // ========================================================================= + + public function test_task_order_by_priority_uses_parameterised_query(): void + { + Task::create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Low task', + 'priority' => 'low', + 'status' => 'pending', + ]); + Task::create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Urgent task', + 'priority' => 'urgent', + 'status' => 'pending', + ]); + Task::create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'High task', + 'priority' => 'high', + 'status' => 'pending', + ]); + + $tasks = Task::forWorkspace($this->workspace->id) + ->orderByPriority() + ->get(); + + $this->assertEquals('Urgent task', $tasks[0]->title); + $this->assertEquals('High task', $tasks[1]->title); + $this->assertEquals('Low task', $tasks[2]->title); + } + + public function test_task_order_by_status_uses_parameterised_query(): void + { + Task::create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Done task', + 'priority' => 'normal', + 'status' => 'done', + ]); + Task::create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'In progress task', + 'priority' => 'normal', + 'status' => 'in_progress', + ]); + Task::create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Pending task', + 'priority' => 'normal', + 'status' => 'pending', + ]); + + $tasks = Task::forWorkspace($this->workspace->id) + ->orderByStatus() + ->get(); + + $this->assertEquals('In progress task', $tasks[0]->title); + $this->assertEquals('Pending task', $tasks[1]->title); + $this->assertEquals('Done task', $tasks[2]->title); + } + + // ========================================================================= + // AgentPlan Model Ordering Tests (SQL Injection Prevention) + // ========================================================================= + + public function test_plan_order_by_status_uses_parameterised_query(): void + { + AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Archived plan', + 'status' => AgentPlan::STATUS_ARCHIVED, + ]); + AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Active plan', + 'status' => AgentPlan::STATUS_ACTIVE, + ]); + AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'title' => 'Draft plan', + 'status' => AgentPlan::STATUS_DRAFT, + ]); + + $plans = AgentPlan::forWorkspace($this->workspace->id) + ->orderByStatus() + ->get(); + + $this->assertEquals('Active plan', $plans[0]->title); + $this->assertEquals('Draft plan', $plans[1]->title); + $this->assertEquals('Archived plan', $plans[2]->title); + } + + // ========================================================================= + // Tool Dependencies Tests + // ========================================================================= + + public function test_state_set_has_workspace_dependency(): void + { + $tool = new StateSet(); + $dependencies = $tool->dependencies(); + + $this->assertNotEmpty($dependencies); + $this->assertEquals('workspace_id', $dependencies[0]->key); + } + + public function test_state_get_has_workspace_dependency(): void + { + $tool = new StateGet(); + $dependencies = $tool->dependencies(); + + $this->assertNotEmpty($dependencies); + $this->assertEquals('workspace_id', $dependencies[0]->key); + } + + public function test_state_list_has_workspace_dependency(): void + { + $tool = new StateList(); + $dependencies = $tool->dependencies(); + + $this->assertNotEmpty($dependencies); + $this->assertEquals('workspace_id', $dependencies[0]->key); + } + + public function test_plan_get_has_workspace_dependency(): void + { + $tool = new PlanGet(); + $dependencies = $tool->dependencies(); + + $this->assertNotEmpty($dependencies); + $this->assertEquals('workspace_id', $dependencies[0]->key); + } + + public function test_plan_list_has_workspace_dependency(): void + { + $tool = new PlanList(); + $dependencies = $tool->dependencies(); + + $this->assertNotEmpty($dependencies); + $this->assertEquals('workspace_id', $dependencies[0]->key); + } +}