feat: place AI-generated documents in the CaseFiles case folder (P7)
GenericTemplateGenerator and FreeDocumentGenerator now, when CaseFilesCore is installed (soft-detected), set the generated Document's folderId to the case "מסמכים" folder via CaseFolderService so it shows in the CaseDocs panel. Existing Case link + NetworkStorage upload unchanged. Verified on dev (P7_OK): php -l clean on both generators; relate/find junction consistency confirmed; folder placement works. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+125
-12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
|
||||
# SmartAssistant — Architecture (developer reference)
|
||||
|
||||
> Internal developer doc. Not shipped to clients. Customer overview: `README.md`. Security/dead-code findings: `_AUDIT/SmartAssistant/findings.md`.
|
||||
|
||||
**Module:** `SmartAssistant` · **Client module:** `smart-assistant` · **Load order:** 37 · **Requires:** EspoCRM ≥ 8.0.0, PHP ≥ 8.1
|
||||
|
||||
## What it is
|
||||
A floating AI legal assistant. A chat widget (case mode / office mode) sends the user message plus rich context (case data, case memory, behavioral rules, prompt sections, learned skills, user profile) to the **shira-hermes** FastAPI backend via a webhook; shira returns response text + a list of tools, which `ActionExecutor` runs against the CRM (task/call/meeting CRUD, status changes, document + memory ops, rule saving), optionally looping tool results back to shira (agentic loop, max 3). Also computes office-wide alerts. Owns 6 entities (AssistantPrompt, AssistantRule, AssistantSkill, CaseMemory, UserProfile, AssistantConversation) and extends `Note` with 3 timeline note types.
|
||||
|
||||
## File-by-file
|
||||
|
||||
### Lifecycle
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `scripts/AfterInstall.php` | Idempotently seeds 7 default AssistantPrompt sections (`skipAll`; admin edits win). No uninstall script (seeded entities persist). |
|
||||
|
||||
### Controllers
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Controllers/SmartAssistant.php` | ~30 actions: chat, execute, executeTool, alerts, summary, history, conversations, caseMemory, saveMemory, document read/analyze/upload/generate/rename/list/browse/write/move. (S1–S6 — **ACL fixed 2026-06-20**: `assertCaseAccess()` on chat/caseMemory/saveMemory/history; document-read endpoints require `caseId` + ACL + `isPathWithinCase()` containment.) |
|
||||
| `Controllers/{AssistantRule,AssistantPrompt,AssistantSkill,CaseMemory,UserProfile}.php` | Thin Record CRUD. **AssistantRule has no owner field — S5.** |
|
||||
|
||||
### Services
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `SmartAssistantService.php` | Orchestrator: webhook call, agentic loop, conversation persistence, stream notes (SmartRequest/Response/Action), office drill-down. (**fixed 2026-06-20**: `detectDrillDown` ACL-filters matched cases (S6); dead static `$conversations` removed (D1).) Forwards espocrmApiKey — S9 still open. |
|
||||
| `ConversationRepository.php` | File-based conversation JSON under `data/conversations/`. |
|
||||
| `ActionExecutor.php` | Executes all shira tools. (**ACL fixed 2026-06-20**: resolves the acting user in `execute()`; central `CASE_TOOL_ACCESS` record-ACL gate for case-bound tools (S1); `assertEntityAccess(...,'delete')` on every delete tool (S4); `save_rule` admin-only (S5); plugin tools with a caseId require case edit — also closes LegalAssistance S1.) |
|
||||
| `CaseContextBuilder.php` / `OfficeContextBuilder.php` | Build case / office context dicts. **Case context IDOR S3.** |
|
||||
| `CaseMemoryContextProvider.php`, `AssistantRuleContextProvider.php`, `AssistantPromptContextProvider.php`, `AssistantSkillContextProvider.php`, `UserProfileContextProvider.php` | Load memories/rules/prompts/skills/profile into the prompt. |
|
||||
| `CaseMemoryService.php` | CaseMemory CRUD (category/importance/pinned). **IDOR S3.** |
|
||||
| `DocumentAnalyzer.php` | Text extraction/analysis; uses NetworkStorageIntegration NetworkDocumentService. (**fixed 2026-06-20**: added `isPathWithinCase()` containment helper the controller calls before any read — S2.) |
|
||||
| `CaseFolderManager.php` | Write/rename/move constrained to the case folder (`resolveSafePath`/`sanitizeRelSegments` — safe). |
|
||||
| `GenericTemplateGenerator.php` / `FreeDocumentGenerator.php` | Template-based / free-form document generation. |
|
||||
| `AlertCalculator.php` | Office alerts (inactive cases, overdue tasks, hearings without prep, unassigned new cases, deadlines). |
|
||||
|
||||
### Hooks
|
||||
| File | Trigger |
|
||||
|---|---|
|
||||
| `Hooks/Case/CaseFieldChangeMemory.php` | afterSave — auto-seed CaseMemory on status/judge/court/next-hearing change. |
|
||||
| `Hooks/Meeting/HearingScheduledMemory.php` | afterSave — memory when a hearing Meeting is created. |
|
||||
|
||||
### Resources / client
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `entityDefs/{AssistantPrompt,AssistantRule,AssistantSkill,CaseMemory,UserProfile,Note,Case}.json` | 6 entities + Note.type extension + Case.caseMemories link. **AssistantRule/UserProfile ACL — S5/S7.** |
|
||||
| `integrations/SmartAssistant.json` | webhookUrl, apiKey, espocrmApiKey, thresholds, agenticLoop. **Keys are varchar — S8.** |
|
||||
| `routes.json` (12), `clientDefs/*`, `scopes/*`, `layouts/*`, `dashlets/SmartAssistant.json`, `i18n/{en_US,fa_IR}/*`, `data/default-prompts.json` | Routes, UI, ACL, dashlet, translations, seed prompts. |
|
||||
| `client/.../src/views/floating-chat.js` | Main chat widget (history, memory browser, mode badge). escape-then-markdown — XSS-safe. |
|
||||
| `client/.../src/views/dashlets/smart-assistant.js` | Alerts card. **Unescaped caseId href S10.** |
|
||||
| `client/.../src/views/{case/modals/add-memory,fields/prompt-editor,site/navbar}.js`, `stream/notes/smart-{request,response,action}.js` | Memory modal, prompt editor, navbar inject, timeline note item views. |
|
||||
|
||||
## Dependencies
|
||||
- **NetworkStorageIntegration (hard):** DocumentAnalyzer document ops.
|
||||
- **shira-hermes (external, hard):** webhook (Integration record `SmartAssistant`). No chat without it.
|
||||
- **KnowledgeBase → SmartAssistant (REVERSE, critical):** KnowledgeBase reuses this extension's Integration record. **Do not uninstall SmartAssistant while KnowledgeBase is installed.**
|
||||
- **LegalCrm (soft):** Note.type extension.
|
||||
|
||||
## Post-install gotchas
|
||||
- Configure the `SmartAssistant` Integration (webhookUrl + apiKey + espocrmApiKey) — scope the espocrmApiKey to a minimal API user (S9).
|
||||
- Known deploy gotchas: a backup-in-modules folder breaks hooks; AfterInstall needs `skipAll`; new role entries need api-key role grant.
|
||||
- shira-hermes `/opt/data` must be a named volume (else skills/profiles/cases wiped on redeploy).
|
||||
@@ -1,120 +1,55 @@
|
||||
# SmartAssistant - עוזר חכם
|
||||
# SmartAssistant — עוזר AI למשרד
|
||||
|
||||
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
**גרסה:** 2.10.5 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
|
||||
## תיאור
|
||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||
|
||||
עוזר AI מאוחד למשרד עורכי דין. צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות,
|
||||
סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל זיכרון תיק מובנה, ביצוע פעולות באישור המשתמש,
|
||||
התראות משרד (תיקים לא פעילים, משימות באיחור, דיונים קרובים), ואינטגרציה עם ה‑Stream של התיק.
|
||||
|
||||
## תלויות
|
||||
- Webhook חיצוני (n8n או דומה) לעיבוד AI
|
||||
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
|
||||
|
||||
- **שירות ה‑AI (shira‑hermes)** — נדרש; העוזר מדבר איתו דרך Webhook. בלעדיו הצ'אט אינו פעיל.
|
||||
- **NetworkStorageIntegration** — נדרש לניתוח וקריאת מסמכי התיק.
|
||||
- **הערה:** אם מותקנת גם **KnowledgeBase**, היא משתמשת בהגדרות האינטגרציה של SmartAssistant — **אין להסיר את SmartAssistant כל עוד KnowledgeBase מותקנת.**
|
||||
|
||||
## התקנה
|
||||
1. התקנה דרך Admin > Extensions
|
||||
2. הגדרת Webhook: Admin > Integrations > Smart Assistant
|
||||
|
||||
## הגדרות אינטגרציה
|
||||
1. הורד את `SmartAssistant-2.10.5.zip` והתקן דרך **ניהול → הרחבות**.
|
||||
2. ב**ניהול → אינטגרציות → Smart Assistant** הזן את כתובת ה‑Webhook, מפתח ה‑API, ומפתח ה‑API של EspoCRM.
|
||||
3. בצע Rebuild ורענון קשיח.
|
||||
|
||||
| שדה | תיאור | דוגמה |
|
||||
|------|--------|--------|
|
||||
| webhookUrl | כתובת Webhook לשירות AI | `https://n8n.example.com/webhook/assistant/chat` |
|
||||
| apiKey | מפתח אימות (אופציונלי) | `sk-...` |
|
||||
| maxMessagesPerHour | הגבלת קצב הודעות | `30` |
|
||||
| inactivityWarningDays | סף אזהרה לחוסר פעילות בתיק | `14` |
|
||||
| inactivityCriticalDays | סף קריטי לחוסר פעילות | `30` |
|
||||
| upcomingHearingDays | חלון דיונים קרובים (ימים) | `7` |
|
||||
## הגדרות
|
||||
|
||||
## אנטיטי: CaseMemory — זיכרון תיק
|
||||
| הגדרה | תיאור |
|
||||
|---|---|
|
||||
| webhookUrl | כתובת שירות ה‑AI (shira‑hermes). |
|
||||
| apiKey | מפתח אימות מול שירות ה‑AI. |
|
||||
| espocrmApiKey | מפתח API שבו שירות ה‑AI חוזר לפנות ל‑EspoCRM (מומלץ לשייך למשתמש API עם הרשאות מצומצמות). |
|
||||
| ספי התראה | ימי חוסר‑פעילות לאזהרה/קריטי, חלון דיונים קרובים. |
|
||||
|
||||
מאגר ידע מובנה לכל תיק, מחולק לקטגוריות:
|
||||
## מה מתווסף למערכת
|
||||
|
||||
| קטגוריה | תיאור |
|
||||
|----------|--------|
|
||||
| key_facts | עובדות מפתח |
|
||||
| strategy | אסטרטגיה |
|
||||
| decisions | החלטות |
|
||||
| contacts_notes | הערות על אנשי קשר |
|
||||
| timeline | ציר זמן |
|
||||
| documents_notes | הערות על מסמכים |
|
||||
| billing_notes | הערות חיוב |
|
||||
- **כפתור צ'אט צף** בכל מסך, עם היסטוריית שיחות ועיון בזיכרון התיק.
|
||||
- **זיכרון תיק (CaseMemory):** מאגר ידע מובנה לכל תיק (עובדות מפתח, אסטרטגיה, החלטות, ציר זמן ועוד) — נשמר ידנית, ע"י העוזר, או אוטומטית בשינויי תיק.
|
||||
- **דשבורד התראות** ותצוגות SmartRequest/SmartResponse/SmartAction בציר הזמן של התיק.
|
||||
- **ניהול מהממשק:** עריכת פרומפטים, כללי התנהגות, מיומנויות ופרופילי משתמש.
|
||||
|
||||
**מקורות:** ידני (manual), עוזר AI (assistant), אוטומטי (auto — hooks)
|
||||
## טיפול בעיות נפוצות
|
||||
|
||||
**רמות חשיבות:** low, normal, high, critical
|
||||
| תסמין | סיבה | פתרון |
|
||||
|---|---|---|
|
||||
| הצ'אט לא מגיב | שירות ה‑AI לא מוגדר/לא זמין | בדוק את webhookUrl ושהשירות (shira‑hermes) רץ. |
|
||||
| KnowledgeBase מפסיקה לעבוד | הוסרה/בוטלה אינטגרציית SmartAssistant | החזר את אינטגרציית SmartAssistant — KnowledgeBase תלויה בה. |
|
||||
| ניתוח מסמכים נכשל | NetworkStorageIntegration לא מותקן | התקן את NetworkStorageIntegration. |
|
||||
|
||||
## רכיבים
|
||||
## הרשאות
|
||||
|
||||
### Backend (PHP)
|
||||
העוזר פועל בגבולות ההרשאות של המשתמש: כל פעולה, קריאת מסמך, זיכרון תיק או "צלילה" לתיק במצב משרד
|
||||
מתבצעת רק על תיקים שהמשתמש מורשה לגשת אליהם, וקריאת מסמכים מוגבלת לתיקיית התיק הרלוונטי בלבד.
|
||||
יצירת **כללי עוזר** (כללים החלים על כל המשרד) שמורה למנהל מערכת.
|
||||
|
||||
| רכיב | קובץ | תיאור |
|
||||
|-------|------|--------|
|
||||
| Controller | `Controllers/SmartAssistant.php` | API endpoints |
|
||||
| שירות ראשי | `Services/SmartAssistantService.php` | תזמור צ'אט, שיחות, פעולות |
|
||||
| בונה הקשר תיק | `Services/CaseContextBuilder.php` | אוסף נתוני תיק מלאים (אנשי קשר, משימות, מסמכים, זיכרון) |
|
||||
| בונה הקשר משרד | `Services/OfficeContextBuilder.php` | סטטיסטיקות, התראות, עומס עבודה |
|
||||
| מחשבון התראות | `Services/AlertCalculator.php` | זיהוי תיקים לא פעילים, משימות באיחור, דיונים קרובים |
|
||||
| מבצע פעולות | `Services/ActionExecutor.php` | ביצוע פעולות שאושרו (משימה, פתק, דיון, שינוי סטטוס) |
|
||||
| שירות זיכרון | `Services/CaseMemoryService.php` | CRUD לזיכרון תיק |
|
||||
| ספק הקשר זיכרון | `Services/CaseMemoryContextProvider.php` | הזרקת זיכרון להקשר AI |
|
||||
| מאגר שיחות | `Services/ConversationRepository.php` | שמירה ואחזור שיחות |
|
||||
| מנתח מסמכים | `Services/DocumentAnalyzer.php` | סריקת מסמכים וסיווג |
|
||||
---
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | תיאור |
|
||||
|------|--------|
|
||||
| `Case/CaseFieldChangeMemory` | יצירת זיכרון אוטומטית בשינוי שדות תיק (סטטוס, שופט, דיון) |
|
||||
| `Meeting/HearingScheduledMemory` | יצירת זיכרון בקביעת דיון חדש |
|
||||
|
||||
### Frontend (JavaScript)
|
||||
|
||||
| רכיב | תיאור |
|
||||
|-------|--------|
|
||||
| `floating-chat.js` | ממשק צ'אט צף (FAB) עם מגירת היסטוריה וזיכרון |
|
||||
| `dashlets/smart-assistant.js` | דשלט עם מדדים והתראות |
|
||||
| `stream/notes/smart-request.js` | תצוגת הודעת משתמש ב-Stream |
|
||||
| `stream/notes/smart-response.js` | תצוגת תשובת עוזר ב-Stream |
|
||||
| `stream/notes/smart-action.js` | תצוגת פעולה שבוצעה ב-Stream |
|
||||
| `case/modals/add-memory.js` | מודל להוספת זיכרון ידנית |
|
||||
| `site/navbar.js` | אינטגרציית התראות בסרגל ניווט |
|
||||
|
||||
## API Routes
|
||||
|
||||
| נתיב | Method | תיאור |
|
||||
|-------|--------|--------|
|
||||
| `/SmartAssistant/action/chat` | POST | שליחת הודעה לעוזר |
|
||||
| `/SmartAssistant/action/execute` | POST | ביצוע פעולה שאושרה |
|
||||
| `/SmartAssistant/action/summary` | GET | סיכום משרדי + התראות |
|
||||
| `/SmartAssistant/action/alerts` | GET | רשימת התראות |
|
||||
| `/SmartAssistant/action/history` | GET | היסטוריית שיחות |
|
||||
| `/SmartAssistant/action/conversations` | GET | רשימת שיחות אחרונות |
|
||||
| `/SmartAssistant/action/conversationMessages` | GET | הודעות שיחה ספציפית |
|
||||
| `/SmartAssistant/action/searchHistory` | GET | חיפוש בשיחות |
|
||||
| `/SmartAssistant/action/caseMemory` | GET | אחזור זיכרון תיק |
|
||||
| `/SmartAssistant/action/saveMemory` | POST | שמירת זיכרון ידנית |
|
||||
|
||||
## פעולות עוזר
|
||||
|
||||
### פעולות מיידיות (ללא אישור)
|
||||
- `list_documents` — רשימת מסמכים בתיק
|
||||
- `save_memory` — שמירת זיכרון תיק
|
||||
|
||||
### פעולות הדורשות אישור
|
||||
- `create_task` — יצירת משימה
|
||||
- `add_note` — הוספת הערה
|
||||
- `change_status` — שינוי סטטוס תיק
|
||||
- `create_meeting` — יצירת פגישה
|
||||
- `schedule_hearing` — קביעת דיון
|
||||
- `analyze_document` — ניתוח מסמך
|
||||
- `rename_document` — שינוי שם מסמך
|
||||
|
||||
## מערכת התראות
|
||||
|
||||
| סוג | סף ברירת מחדל | חומרה |
|
||||
|------|----------------|--------|
|
||||
| תיק לא פעיל | 14 יום | אזהרה |
|
||||
| תיק לא פעיל | 30 יום | קריטי |
|
||||
| משימה באיחור | תאריך יעד עבר | קריטי |
|
||||
| דיון קרוב | 7 ימים | אזהרה |
|
||||
| תיק ללא שיוך | חדש ללא assignedUser | אזהרה |
|
||||
| משימה בעדיפות גבוהה | 5 ימים | אזהרה |
|
||||
> תיעוד טכני למפתחים (שירותים, פעולות, hooks, זרימת ה‑webhook): `ARCHITECTURE.md`.
|
||||
|
||||
@@ -8,6 +8,8 @@ use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||
@@ -22,12 +24,14 @@ class SmartAssistant
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Acl $acl;
|
||||
private User $user;
|
||||
private EntityManager $entityManager;
|
||||
|
||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
|
||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user, EntityManager $entityManager)
|
||||
{
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->acl = $acl;
|
||||
$this->user = $user;
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
private function getService(): SmartAssistantService
|
||||
@@ -42,6 +46,24 @@ class SmartAssistant
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record-level ACL on a specific case. The scope check in checkAccess() is
|
||||
* not enough — without this, any user with Case-scope read could read/write
|
||||
* the data of ANY case by passing its id (findings S2/S3).
|
||||
*/
|
||||
private function assertCaseAccess(string $caseId, string $action = 'read'): void
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
|
||||
if (!$case) {
|
||||
throw new NotFound('Case not found.');
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($case, $action)) {
|
||||
throw new Forbidden('No access to this case.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getActionStatus(Request $request, Response $response): array
|
||||
{
|
||||
return $this->getService()->getStatus();
|
||||
@@ -63,6 +85,10 @@ class SmartAssistant
|
||||
throw new BadRequest('caseId is required for case mode.');
|
||||
}
|
||||
|
||||
if ($mode === 'case') {
|
||||
$this->assertCaseAccess($caseId, 'read');
|
||||
}
|
||||
|
||||
return $this->getService()->chat(
|
||||
trim($data->message),
|
||||
$mode,
|
||||
@@ -103,6 +129,11 @@ class SmartAssistant
|
||||
{
|
||||
$this->checkAccess();
|
||||
$caseId = $request->getQueryParam('caseId');
|
||||
|
||||
if (!empty($caseId)) {
|
||||
$this->assertCaseAccess($caseId, 'read');
|
||||
}
|
||||
|
||||
return $this->getService()->getHistory($caseId);
|
||||
}
|
||||
|
||||
@@ -146,6 +177,8 @@ class SmartAssistant
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
|
||||
$this->assertCaseAccess($caseId, 'read');
|
||||
|
||||
$category = $request->getQueryParam('category');
|
||||
$includeArchived = (bool) $request->getQueryParam('includeArchived');
|
||||
|
||||
@@ -162,6 +195,8 @@ class SmartAssistant
|
||||
throw new BadRequest('caseId and content are required.');
|
||||
}
|
||||
|
||||
$this->assertCaseAccess($data->caseId, 'edit');
|
||||
|
||||
$service = $this->injectableFactory->create(CaseMemoryService::class);
|
||||
$entry = $service->createMemory(
|
||||
$data->caseId,
|
||||
@@ -206,9 +241,20 @@ class SmartAssistant
|
||||
throw new BadRequest('filePath is required.');
|
||||
}
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
$this->assertCaseAccess($caseId, 'read');
|
||||
|
||||
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
|
||||
if (!$analyzer->isPathWithinCase($filePath, $caseId)) {
|
||||
throw new Forbidden('File is outside the case folder.');
|
||||
}
|
||||
|
||||
$text = $analyzer->extractFullText($filePath, $maxLength);
|
||||
|
||||
return [
|
||||
@@ -229,7 +275,18 @@ class SmartAssistant
|
||||
throw new BadRequest('filePath is required.');
|
||||
}
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
$this->assertCaseAccess($caseId, 'read');
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
|
||||
if (!$analyzer->isPathWithinCase($filePath, $caseId)) {
|
||||
throw new Forbidden('File is outside the case folder.');
|
||||
}
|
||||
|
||||
return $analyzer->getDocumentBytes($filePath);
|
||||
}
|
||||
|
||||
@@ -243,9 +300,22 @@ class SmartAssistant
|
||||
throw new BadRequest('filePaths is required (array of file paths).');
|
||||
}
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
$this->assertCaseAccess($caseId, 'read');
|
||||
|
||||
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
|
||||
foreach ((array) $filePaths as $fp) {
|
||||
if (!$analyzer->isPathWithinCase($fp, $caseId)) {
|
||||
throw new Forbidden('One or more files are outside the case folder.');
|
||||
}
|
||||
}
|
||||
|
||||
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
|
||||
|
||||
return [
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
@@ -15,13 +18,52 @@ class ActionExecutor
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Log $log;
|
||||
private Metadata $metadata;
|
||||
private AclManager $aclManager;
|
||||
|
||||
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata)
|
||||
/** The user on whose behalf the current tool batch runs (set in execute()). */
|
||||
private ?Entity $actingUser = null;
|
||||
|
||||
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata, AclManager $aclManager)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->log = $log;
|
||||
$this->metadata = $metadata;
|
||||
$this->aclManager = $aclManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that act within a Case context, and the ACL action each requires on
|
||||
* that Case. EspoCRM does NOT auto-ACL agent tools, so executeTool would
|
||||
* otherwise let any user with Case-scope read create/modify/read data on
|
||||
* ANY case by passing its id. The central gate in execute() closes that.
|
||||
*/
|
||||
private const CASE_TOOL_ACCESS = [
|
||||
'create_task' => 'edit',
|
||||
'add_note' => 'edit',
|
||||
'change_status' => 'edit',
|
||||
'create_meeting' => 'edit',
|
||||
'create_call' => 'edit',
|
||||
'schedule_hearing' => 'edit',
|
||||
'save_memory' => 'edit',
|
||||
'upload_document' => 'edit',
|
||||
'generate_document' => 'edit',
|
||||
'merge_documents' => 'edit',
|
||||
'list_documents' => 'read',
|
||||
'analyze_document' => 'read',
|
||||
'read_document' => 'read',
|
||||
'read_multiple_documents' => 'read',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the acting user and assert record-level access on the entity.
|
||||
* Used by the delete tools, which key off an entityId rather than a caseId.
|
||||
*/
|
||||
private function assertEntityAccess(Entity $entity, string $action): void
|
||||
{
|
||||
if (!$this->actingUser || !$this->aclManager->checkEntity($this->actingUser, $entity, $action)) {
|
||||
throw new Forbidden('No access to the requested record.');
|
||||
}
|
||||
}
|
||||
|
||||
public const VALID_STATUSES = [
|
||||
@@ -36,6 +78,32 @@ class ActionExecutor
|
||||
{
|
||||
$this->log->debug("SmartAssistant: Executing tool={$tool} for case={$caseId}");
|
||||
|
||||
// Resolve the acting user once; every ACL check below is on their behalf.
|
||||
$this->actingUser = $this->entityManager->getEntityById('User', $userId);
|
||||
|
||||
if (!$this->actingUser) {
|
||||
throw new Forbidden('Invalid user context.');
|
||||
}
|
||||
|
||||
// Central record-level ACL gate for every case-bound tool. Without this,
|
||||
// a generic "Case read" scope check (in the controller) transitively
|
||||
// authorised writing to and reading from ANY case (findings S1/S3).
|
||||
if (isset(self::CASE_TOOL_ACCESS[$tool])) {
|
||||
if (!$caseId) {
|
||||
throw new Error("Tool {$tool} requires a case context.");
|
||||
}
|
||||
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
|
||||
if (!$case) {
|
||||
throw new NotFound('Case not found.');
|
||||
}
|
||||
|
||||
if (!$this->aclManager->checkEntity($this->actingUser, $case, self::CASE_TOOL_ACCESS[$tool])) {
|
||||
throw new Forbidden('No access to this case.');
|
||||
}
|
||||
}
|
||||
|
||||
return match ($tool) {
|
||||
'create_task' => $this->createTask($params, $caseId, $userId),
|
||||
'add_note' => $this->addNote($params, $caseId, $userId),
|
||||
@@ -91,6 +159,14 @@ class ActionExecutor
|
||||
|
||||
private function saveRule(array $params, string $userId): array
|
||||
{
|
||||
// AssistantRule rows are injected into the system prompt for other users
|
||||
// (case/office/global scope), so creating them is an admin-only operation.
|
||||
// Without this, any user could plant firm-wide instructions (stored
|
||||
// prompt injection — finding S5).
|
||||
if (!$this->actingUser || !$this->actingUser->get('isAdmin')) {
|
||||
throw new Forbidden('Only an administrator can create assistant rules.');
|
||||
}
|
||||
|
||||
$name = $params['name'] ?? '';
|
||||
$rule = $params['rule'] ?? '';
|
||||
if (!$name || !$rule) {
|
||||
@@ -237,6 +313,8 @@ class ActionExecutor
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->assertEntityAccess($entity, 'delete');
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
@@ -260,6 +338,8 @@ class ActionExecutor
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->assertEntityAccess($entity, 'delete');
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
@@ -282,6 +362,8 @@ class ActionExecutor
|
||||
throw new NotFound("Note {$entityId} not found.");
|
||||
}
|
||||
|
||||
$this->assertEntityAccess($entity, 'delete');
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
@@ -305,6 +387,8 @@ class ActionExecutor
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->assertEntityAccess($entity, 'delete');
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
@@ -604,6 +688,22 @@ class ActionExecutor
|
||||
throw new Error("Unknown tool: {$tool}");
|
||||
}
|
||||
|
||||
// Plugin tools (e.g. LegalAssistance's report generator) act on a case
|
||||
// when one is supplied. Enforce record-level edit ACL here so a plugin
|
||||
// tool can't be driven against a case the user can't access — this is
|
||||
// the central gate that also closes LegalAssistance finding S1.
|
||||
if ($caseId) {
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
|
||||
if (!$case) {
|
||||
throw new NotFound('Case not found.');
|
||||
}
|
||||
|
||||
if (!$this->actingUser || !$this->aclManager->checkEntity($this->actingUser, $case, 'edit')) {
|
||||
throw new Forbidden('No access to this case.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
|
||||
|
||||
$handler = $this->injectableFactory->create($handlerClass);
|
||||
|
||||
@@ -346,6 +346,36 @@ class DocumentAnalyzer
|
||||
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
||||
* source: 'stored' | 'computed' | 'none'
|
||||
*/
|
||||
/**
|
||||
* Containment check: is $filePath inside the given case's storage folder?
|
||||
* Used to stop a user with access to one case from reading another case's
|
||||
* files by passing a raw absolute/relative path (finding S2). Rejects any
|
||||
* path containing '..' and requires a resolvable case folder.
|
||||
*/
|
||||
public function isPathWithinCase(string $filePath, string $caseId): bool
|
||||
{
|
||||
$normalized = str_replace('\\', '/', (string) $filePath);
|
||||
|
||||
if ($normalized === '' || str_contains($normalized, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$caseFolder = $this->describeCaseFolderPath($caseId)['path'] ?? null;
|
||||
|
||||
if (!$caseFolder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$needle = ltrim(rtrim(str_replace('\\', '/', $caseFolder), '/'), '/');
|
||||
$candidate = ltrim($normalized, '/');
|
||||
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $candidate === $needle || str_starts_with($candidate, $needle . '/');
|
||||
}
|
||||
|
||||
public function describeCaseFolderPath(string $caseId): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
|
||||
@@ -89,6 +89,19 @@ class FreeDocumentGenerator
|
||||
->getRelation($case, 'documents')
|
||||
->relate($document);
|
||||
|
||||
// CaseFiles backend: place the Document in the case "מסמכים" folder.
|
||||
$caseFilesServiceClass = 'Espo\\Modules\\CaseFilesCore\\Services\\CaseFolderService';
|
||||
if (class_exists($caseFilesServiceClass)) {
|
||||
try {
|
||||
$cfFolder = $this->injectableFactory->create($caseFilesServiceClass)
|
||||
->getOrCreateSubfolder('Case', $caseId, 'מסמכים');
|
||||
$document->set('folderId', $cfFolder->getId());
|
||||
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true, 'silent' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log->warning('FreeDocumentGenerator: CaseFiles folder placement failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$networkPath = null;
|
||||
try {
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
|
||||
@@ -114,6 +114,19 @@ class GenericTemplateGenerator
|
||||
->getRelation($case, 'documents')
|
||||
->relate($document);
|
||||
|
||||
// CaseFiles backend: place the Document in the case "מסמכים" folder.
|
||||
$caseFilesServiceClass = 'Espo\\Modules\\CaseFilesCore\\Services\\CaseFolderService';
|
||||
if (class_exists($caseFilesServiceClass)) {
|
||||
try {
|
||||
$cfFolder = $this->injectableFactory->create($caseFilesServiceClass)
|
||||
->getOrCreateSubfolder('Case', $caseId, 'מסמכים');
|
||||
$document->set('folderId', $cfFolder->getId());
|
||||
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true, 'silent' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log->warning('GenericTemplateGenerator: CaseFiles folder placement failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Upload to the case folder; align DB names to the canonical stored name.
|
||||
try {
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Espo\Modules\SmartAssistant\Services;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
@@ -35,21 +36,22 @@ class SmartAssistantService
|
||||
private Config $config;
|
||||
private Log $log;
|
||||
private User $user;
|
||||
|
||||
private static array $conversations = [];
|
||||
private AclManager $aclManager;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
InjectableFactory $injectableFactory,
|
||||
Config $config,
|
||||
Log $log,
|
||||
User $user
|
||||
User $user,
|
||||
AclManager $aclManager
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->config = $config;
|
||||
$this->log = $log;
|
||||
$this->user = $user;
|
||||
$this->aclManager = $aclManager;
|
||||
}
|
||||
|
||||
private function getConversationRepo(): ConversationRepository
|
||||
@@ -486,6 +488,13 @@ class SmartAssistantService
|
||||
|
||||
foreach ($cases as $case) {
|
||||
if (mb_stripos($message, $case->get('name')) !== false) {
|
||||
// Record-level ACL: only drill into a case the user may read.
|
||||
// Without this, naming any case in office-mode chat would surface
|
||||
// its data regardless of access (finding S6).
|
||||
if (!$this->aclManager->checkEntity($this->user, $case, 'read')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class);
|
||||
return $contextBuilder->buildCaseDrillDown($case->get('id'), $this->user);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user