Compare commits

..

1 Commits

Author SHA1 Message Date
chaim 0f9d06739e fix(2.10.3): expose AssistantRule + CaseMemory in Admin Layout Manager
Two metadata gaps spotted when the user couldn't see Shira entities in
Admin → Layout Manager and got console 404s on /#CaseMemory:

* AssistantRule.json (scopes/) was missing `customizable: true` and
  `importable: false`. Without `customizable: true` EspoCRM hides the
  entity from Admin → Entity Manager → Layouts. Other Shira entities
  already had it; this one was the outlier.

* CaseMemory.json (clientDefs/) didn't exist at all. The Metadata
  endpoint returned an empty object, so the frontend fell through to
  default convention `client/custom/modules/smart-assistant/src/controllers/case-memory.js`
  which doesn't exist → 404 in browser console. Adding clientDefs with
  `controller: controllers/record` + icon + filters lets the standard
  record controller handle the entity without any custom JS file.

Verified on prod after hot-patch:
- Metadata?key=scopes.AssistantRule.customizable → "true"
- Metadata?key=clientDefs.CaseMemory → full object

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:52:13 +00:00
12 changed files with 162 additions and 651 deletions
File diff suppressed because one or more lines are too long
-62
View File
@@ -1,62 +0,0 @@
# 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. (S1S6 — **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).
+102 -37
View File
@@ -1,55 +1,120 @@
# SmartAssistant עוזר AI למשרד
# SmartAssistant - עוזר חכם
**גרסה:** 2.10.5 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות,
סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל זיכרון תיק מובנה, ביצוע פעולות באישור המשתמש,
התראות משרד (תיקים לא פעילים, משימות באיחור, דיונים קרובים), ואינטגרציה עם ה‑Stream של התיק.
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
## תלויות
- **שירות ה‑AI (shirahermes)** — נדרש; העוזר מדבר איתו דרך Webhook. בלעדיו הצ'אט אינו פעיל.
- **NetworkStorageIntegration** — נדרש לניתוח וקריאת מסמכי התיק.
- **הערה:** אם מותקנת גם **KnowledgeBase**, היא משתמשת בהגדרות האינטגרציה של SmartAssistant — **אין להסיר את SmartAssistant כל עוד KnowledgeBase מותקנת.**
- Webhook חיצוני (n8n או דומה) לעיבוד AI
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
## התקנה
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` |
| הגדרה | תיאור |
|---|---|
| webhookUrl | כתובת שירות ה‑AI (shirahermes). |
| apiKey | מפתח אימות מול שירות ה‑AI. |
| espocrmApiKey | מפתח API שבו שירות ה‑AI חוזר לפנות ל‑EspoCRM (מומלץ לשייך למשתמש API עם הרשאות מצומצמות). |
| ספי התראה | ימי חוסר‑פעילות לאזהרה/קריטי, חלון דיונים קרובים. |
## אנטיטי: CaseMemory — זיכרון תיק
## מה מתווסף למערכת
מאגר ידע מובנה לכל תיק, מחולק לקטגוריות:
- **כפתור צ'אט צף** בכל מסך, עם היסטוריית שיחות ועיון בזיכרון התיק.
- **זיכרון תיק (CaseMemory):** מאגר ידע מובנה לכל תיק (עובדות מפתח, אסטרטגיה, החלטות, ציר זמן ועוד) — נשמר ידנית, ע"י העוזר, או אוטומטית בשינויי תיק.
- **דשבורד התראות** ותצוגות SmartRequest/SmartResponse/SmartAction בציר הזמן של התיק.
- **ניהול מהממשק:** עריכת פרומפטים, כללי התנהגות, מיומנויות ופרופילי משתמש.
| קטגוריה | תיאור |
|----------|--------|
| key_facts | עובדות מפתח |
| strategy | אסטרטגיה |
| decisions | החלטות |
| contacts_notes | הערות על אנשי קשר |
| timeline | ציר זמן |
| documents_notes | הערות על מסמכים |
| billing_notes | הערות חיוב |
## טיפול בעיות נפוצות
**מקורות:** ידני (manual), עוזר AI (assistant), אוטומטי (auto — hooks)
| תסמין | סיבה | פתרון |
|---|---|---|
| הצ'אט לא מגיב | שירות ה‑AI לא מוגדר/לא זמין | בדוק את webhookUrl ושהשירות (shirahermes) רץ. |
| KnowledgeBase מפסיקה לעבוד | הוסרה/בוטלה אינטגרציית SmartAssistant | החזר את אינטגרציית SmartAssistant — KnowledgeBase תלויה בה. |
| ניתוח מסמכים נכשל | NetworkStorageIntegration לא מותקן | התקן את NetworkStorageIntegration. |
**רמות חשיבות:** low, normal, high, critical
## הרשאות
## רכיבים
העוזר פועל בגבולות ההרשאות של המשתמש: כל פעולה, קריאת מסמך, זיכרון תיק או "צלילה" לתיק במצב משרד
מתבצעת רק על תיקים שהמשתמש מורשה לגשת אליהם, וקריאת מסמכים מוגבלת לתיקיית התיק הרלוונטי בלבד.
יצירת **כללי עוזר** (כללים החלים על כל המשרד) שמורה למנהל מערכת.
### 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, זרימת ה‑webhook): `ARCHITECTURE.md`.
### 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 ימים | אזהרה |
@@ -8,8 +8,6 @@ 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;
@@ -24,14 +22,12 @@ class SmartAssistant
private InjectableFactory $injectableFactory;
private Acl $acl;
private User $user;
private EntityManager $entityManager;
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user, EntityManager $entityManager)
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
{
$this->injectableFactory = $injectableFactory;
$this->acl = $acl;
$this->user = $user;
$this->entityManager = $entityManager;
}
private function getService(): SmartAssistantService
@@ -46,24 +42,6 @@ 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();
@@ -85,10 +63,6 @@ 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,
@@ -129,11 +103,6 @@ class SmartAssistant
{
$this->checkAccess();
$caseId = $request->getQueryParam('caseId');
if (!empty($caseId)) {
$this->assertCaseAccess($caseId, 'read');
}
return $this->getService()->getHistory($caseId);
}
@@ -177,8 +146,6 @@ class SmartAssistant
throw new BadRequest('caseId is required.');
}
$this->assertCaseAccess($caseId, 'read');
$category = $request->getQueryParam('category');
$includeArchived = (bool) $request->getQueryParam('includeArchived');
@@ -195,8 +162,6 @@ 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,
@@ -241,20 +206,9 @@ 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 [
@@ -275,18 +229,7 @@ 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);
}
@@ -300,22 +243,9 @@ 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 [
@@ -1,10 +0,0 @@
{
"controller": "controllers/record",
"color": "#5e8c61",
"iconClass": "fas fa-list-check",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "active"},
{"name": "inactive"}
]
}
@@ -3,11 +3,8 @@
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;
@@ -18,52 +15,13 @@ class ActionExecutor
private InjectableFactory $injectableFactory;
private Log $log;
private Metadata $metadata;
private AclManager $aclManager;
/** 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)
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata)
{
$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 = [
@@ -78,32 +36,6 @@ 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),
@@ -159,14 +91,6 @@ 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) {
@@ -313,8 +237,6 @@ class ActionExecutor
}
$name = $entity->get('name') ?? '';
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -338,8 +260,6 @@ class ActionExecutor
}
$name = $entity->get('name') ?? '';
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -362,8 +282,6 @@ class ActionExecutor
throw new NotFound("Note {$entityId} not found.");
}
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -387,8 +305,6 @@ class ActionExecutor
}
$name = $entity->get('name') ?? '';
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -688,22 +604,6 @@ 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);
@@ -11,7 +11,6 @@ use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
/**
* Wraps NetworkDocumentService and constrains every operation to the case's
@@ -35,32 +34,14 @@ class CaseFolderManager
*/
public function writeFile(string $caseId, string $relPath, string $content): array
{
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
// Split the (LLM-provided) relPath into folder + filename. Subfolders
// are allowed, but the final filename is sanitized and de-duplicated by
// the canonical namer (rule N1) — never trust the model to name files.
$normalized = $this->normalize($relPath);
if ($normalized === '') {
throw new BadRequest('Path is required.');
$parent = $this->dirnameRel($absInsideStorage);
if ($parent !== '') {
$client->createFolderRecursive($parent);
}
$slash = strrpos($normalized, '/');
$dirRel = $slash === false ? '' : substr($normalized, 0, $slash);
$baseName = $slash === false ? $normalized : substr($normalized, $slash + 1);
$ext = pathinfo($baseName, PATHINFO_EXTENSION) ?: 'bin';
$subject = pathinfo($baseName, PATHINFO_FILENAME);
$parentAbs = $dirRel === ''
? $this->getCaseRoot($caseId)
: $this->resolveSafePath($caseId, $this->sanitizeRelSegments($dirRel), requireExists: false);
$client->createFolderRecursive($parentAbs);
$storedName = LocalFilesystemClient::buildStoredFileName($client, $parentAbs, $subject, $ext);
$absInsideStorage = $parentAbs . '/' . $storedName;
$ok = $client->uploadFile($absInsideStorage, $content);
if (!$ok) {
throw new Error("Failed to write file: {$absInsideStorage}");
@@ -80,12 +61,7 @@ class CaseFolderManager
*/
public function createFolder(string $caseId, string $relPath): array
{
$cleanRel = $this->sanitizeRelSegments($this->normalize($relPath));
if ($cleanRel === '') {
throw new BadRequest('A valid folder name is required.');
}
$abs = $this->resolveSafePath($caseId, $cleanRel, requireExists: false);
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
$alreadyExisted = $client->exists($abs);
@@ -115,12 +91,6 @@ class CaseFolderManager
throw new BadRequest('newName must be a plain file/folder name without slashes.');
}
// Sanitize the model-chosen name (rule N1).
$newName = LocalFilesystemClient::sanitizeFileName($newName);
if ($newName === '') {
throw new BadRequest('newName is empty after sanitization.');
}
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
$parent = $this->dirnameRel($absCurrent);
@@ -154,11 +124,7 @@ class CaseFolderManager
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
{
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
$absTarget = $this->resolveSafePath(
$caseId,
$this->sanitizeRelSegments($this->normalize($targetRelPath)),
requireExists: false
);
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
$client = $this->getNds()->getClient();
@@ -331,26 +297,6 @@ class CaseFolderManager
return implode('/', $out);
}
/**
* Sanitize every segment of an already-normalized storage-relative path
* with the canonical file-name rules (rule N1). Empty segments are dropped.
*/
private function sanitizeRelSegments(string $normalizedPath): string
{
if ($normalizedPath === '') {
return '';
}
$segments = array_map(
static fn (string $seg): string => LocalFilesystemClient::sanitizeFileName($seg),
explode('/', $normalizedPath)
);
$segments = array_filter($segments, static fn (string $seg): bool => $seg !== '');
return implode('/', $segments);
}
private function dirnameRel(string $path): string
{
$pos = strrpos($path, '/');
@@ -346,36 +346,6 @@ 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);
@@ -9,7 +9,6 @@ use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
@@ -51,16 +50,9 @@ class FreeDocumentGenerator
$docxBinary = $this->buildDocx($title, $body, $recipient);
// Subject-only naming (rule N1): no date prefix. The canonical stored
// name (+ "-{NNN}" on collision) is produced by NetworkDocumentService
// on upload; we then align the Espo Document/Attachment to it so the
// CRM, the attachment and the file on disk never disagree.
$baseName = LocalFilesystemClient::sanitizeFileName($title);
if ($baseName === '') {
$baseName = 'document';
}
$fileName = $baseName . '.docx';
$documentName = $title;
$sanitizedTitle = $this->sanitizeFileName($title);
$today = date('Y-m-d');
$fileName = "{$today} - {$sanitizedTitle}.docx";
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
@@ -75,71 +67,23 @@ class FreeDocumentGenerator
$document = $this->entityManager->getNewEntity('Document');
$document->set([
'name' => $documentName,
'name' => $title,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
// Suppress the NSI upload hook: it fires on this save, before the Case
// link below exists, so it would file the document in the fallback
// folder. We upload explicitly (with the Case) right after.
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
$this->entityManager->saveEntity($document);
$this->entityManager->getRDBRepository('Case')
->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);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
if (!empty($result['success'])) {
$networkPath = $result['path'] ?? null;
$storedName = $result['fileName'] ?? $fileName;
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $documentName;
// Align DB names to the canonical stored name.
$document->set([
'name' => $storedBase,
'fileName' => $storedName,
'storageType' => 'network',
'networkStoragePath' => $networkPath,
'networkStorageFileName' => $storedName,
'networkStorageSize' => $result['size'] ?? strlen($docxBinary),
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
]);
$this->entityManager->saveEntity($document, [
'skipNetworkUpload' => true,
'silent' => true,
]);
$attachment->set('name', $storedName);
$this->entityManager->saveEntity($attachment, ['silent' => true]);
// The file now lives in network storage; drop the local copy.
$sourcePath = 'data/upload/' . $attachment->getSourceId();
if (file_exists($sourcePath)) {
@unlink($sourcePath);
}
$fileName = $storedName;
$documentName = $storedBase;
}
$networkPath = $result['path'] ?? null;
}
} catch (\Throwable $e) {
// Document is safely persisted in Espo; the network-storage copy is best-effort.
@@ -150,10 +94,10 @@ class FreeDocumentGenerator
}
$this->log->info(
"FreeDocumentGenerator: Created '{$documentName}' (type={$documentType}) for Case {$caseId}"
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
);
$message = "✅ המסמך \"{$documentName}\" נוצר בהצלחה וצורף לתיק";
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
if ($networkPath) {
$message .= " ולתיקיית הרשת";
}
@@ -162,7 +106,7 @@ class FreeDocumentGenerator
return [
'success' => true,
'documentId' => $document->getId(),
'documentName' => $documentName,
'documentName' => $title,
'fileName' => $fileName,
'networkPath' => $networkPath,
'message' => $message,
@@ -321,4 +265,11 @@ class FreeDocumentGenerator
$year = $dt->format('Y');
return "{$day} ב{$month} {$year}";
}
private function sanitizeFileName(string $name): string
{
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
$name = preg_replace('/\s+/', ' ', $name);
return trim(mb_substr($name, 0, 100));
}
}
@@ -10,7 +10,6 @@ use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
use PhpOffice\PhpWord\TemplateProcessor;
class GenericTemplateGenerator
@@ -73,23 +72,14 @@ class GenericTemplateGenerator
@unlink($outputPath);
@unlink($localTemplatePath);
// Step 6: Build subject-only document name (rule N1) — no contact,
// no em-dash. Defaults to the template name when no subject given.
$subject = trim((string) ($documentSubject ?? ''));
if ($subject === '') {
$subject = (string) $templateName;
}
$docName = $subject;
$baseName = LocalFilesystemClient::sanitizeFileName($subject);
if ($baseName === '') {
$baseName = 'document';
}
$fileName = $baseName . '.docx';
// Step 6: Build document name
$contactName = $this->getContactName($case);
$docName = $documentSubject ?? "{$templateName}{$contactName}";
// Step 7: Create Attachment + Document
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
'name' => $fileName,
'name' => $docName . '.docx',
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'role' => 'Attachment',
'size' => strlen($content),
@@ -102,74 +92,16 @@ class GenericTemplateGenerator
$document->set([
'name' => $docName,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'fileName' => $docName . '.docx',
'assignedUserId' => $this->user->getId(),
]);
// Suppress the NSI upload hook (fires before the Case link below);
// we upload explicitly with the Case so it lands in the case folder.
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
$this->entityManager->saveEntity($document);
// Link to Case
$this->entityManager->getRDBRepository('Case')
->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);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
if (!empty($result['success'])) {
$storedName = $result['fileName'] ?? $fileName;
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $docName;
$document->set([
'name' => $storedBase,
'fileName' => $storedName,
'storageType' => 'network',
'networkStoragePath' => $result['path'] ?? null,
'networkStorageFileName' => $storedName,
'networkStorageSize' => $result['size'] ?? strlen($content),
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
]);
$this->entityManager->saveEntity($document, [
'skipNetworkUpload' => true,
'silent' => true,
]);
$attachment->set('name', $storedName);
$this->entityManager->saveEntity($attachment, ['silent' => true]);
$sourcePath = 'data/upload/' . $attachment->getSourceId();
if (file_exists($sourcePath)) {
@unlink($sourcePath);
}
$docName = $storedBase;
}
}
} catch (\Throwable $e) {
// Document is safely persisted in Espo; the network copy is best-effort.
$this->log->warning(
"GenericTemplateGenerator: failed to copy to network storage for Case {$caseId}: " .
$e->getMessage()
);
}
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
return [
@@ -258,4 +190,24 @@ class GenericTemplateGenerator
$processor->saveAs($outputPath);
}
private function getContactName($case): string
{
$contactId = $case->get('contactId');
if (!$contactId) {
$ids = $case->getLinkMultipleIdList('contacts');
if (!empty($ids)) {
$contactId = $ids[0];
}
}
if ($contactId) {
$contact = $this->entityManager->getEntityById('Contact', $contactId);
if ($contact) {
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
}
}
return $case->get('name') ?? '';
}
}
@@ -5,7 +5,6 @@ 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;
@@ -36,22 +35,21 @@ class SmartAssistantService
private Config $config;
private Log $log;
private User $user;
private AclManager $aclManager;
private static array $conversations = [];
public function __construct(
EntityManager $entityManager,
InjectableFactory $injectableFactory,
Config $config,
Log $log,
User $user,
AclManager $aclManager
User $user
) {
$this->entityManager = $entityManager;
$this->injectableFactory = $injectableFactory;
$this->config = $config;
$this->log = $log;
$this->user = $user;
$this->aclManager = $aclManager;
}
private function getConversationRepo(): ConversationRepository
@@ -488,13 +486,6 @@ 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);
}
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "2.11.0",
"version": "2.10.3",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-06-21",
"releaseDate": "2026-05-27",
"php": [
">=8.1"
]