security: fix SQL injection and add workspace scoping to MCP tools

- Replace orderByRaw with parameterised CASE statements
- Add Task::scopeOrderByPriority() and scopeOrderByStatus()
- Add AgentPlan::scopeOrderByStatus()
- Add workspace validation to StateSet, StateGet, StateList tools
- Add workspace validation to PlanGet, PlanList tools
- Add SecurityTest.php with comprehensive isolation tests

Fixes SEC-002, SEC-003 from security audit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 12:21:01 +00:00
parent 577118f7f2
commit a2a9423ad6
15 changed files with 2489 additions and 16 deletions

View file

@ -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();

View file

@ -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();

View file

@ -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<ToolDependency>
*/
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'));
}
}

View file

@ -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<ToolDependency>
*/
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(),
];
]);
}
}

View file

@ -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<ToolDependency>
*/
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(),
];
]);
}
}

View file

@ -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<ToolDependency>
*/
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(),
];
]);
}
}

View file

@ -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<ToolDependency>
*/
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}");

View file

@ -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
{

View file

@ -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) {

283
TODO.md Normal file
View file

@ -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

319
docs/api-keys.md Normal file
View file

@ -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!
```

322
docs/architecture.md Normal file
View file

@ -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

670
docs/mcp-tools.md Normal file
View file

@ -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.

279
docs/security.md Normal file
View file

@ -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

View file

@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentWorkspaceState;
use Core\Mod\Agentic\Models\Task;
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanGet;
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanList;
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateGet;
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateList;
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateSet;
use Core\Tenant\Models\Workspace;
use Tests\TestCase;
/**
* Security tests for workspace isolation and SQL injection prevention.
*/
class SecurityTest extends TestCase
{
use RefreshDatabase;
private Workspace $workspace;
private Workspace $otherWorkspace;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}