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:
parent
577118f7f2
commit
a2a9423ad6
15 changed files with 2489 additions and 16 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
283
TODO.md
Normal 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
319
docs/api-keys.md
Normal 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
322
docs/architecture.md
Normal 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
670
docs/mcp-tools.md
Normal 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
279
docs/security.md
Normal 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
|
||||
432
tests/Feature/SecurityTest.php
Normal file
432
tests/Feature/SecurityTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue