fix(operation-trace): record uninstalls and deletes, drop assistant chatter

- Split the route rules on the HTTP method so a DELETE is classified as a
  destructive action instead of falling into the read/write bucket. Uninstalling
  a plugin or skill and deleting a knowledge base or MCP server were recorded as
  a view/update and left no readable trace.
- Add plugin_uninstall, skill_uninstall, knowledge_base_delete and mcp_delete.
- Skip the assistant's own conversation traffic: a chat session produced opaque
  create/resource rows that answered nothing.
- Localize the new actions and the assistant_conversation resource type in all locales.
- Sync the assistant tool action list and apply ruff formatting.
This commit is contained in:
TyperBody
2026-09-28 02:06:57 +08:00
parent b0fab2386e
commit 67ff4d3054
10 changed files with 157 additions and 38 deletions
@@ -173,17 +173,34 @@ class CreateKnowledgeBase(CreatePipeline):
#: further (e.g. the audit surface needs ``audit.view``, which only the owner
#: and admin hold) and both are enforced identically when listing and calling.
TOOLS = {
'list_resources': (ListResources, 'List existing Workspace resources. Discover IDs before using them.', False, None),
'list_resources': (
ListResources,
'List existing Workspace resources. Discover IDs before using them.',
False,
None,
),
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None),
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None),
'list_operation_logs': (
ListOperationLogs,
'Read the Workspace operation trace (who changed or viewed what, with before/after fields). '
'Owner and admin only. Use it to answer "who did what" questions about this Workspace.',
'Search the Workspace operation trace (who changed or viewed what, with before/after fields). '
'Owner and admin only. Always pass a narrow filter instead of dumping everything: use "search" '
'for a substring such as a plugin name, an account or a verb (e.g. search="LangTARS" or '
'search="install"); use the exact "action" key only when known - valid keys include '
'plugin_install, plugin_uninstall, plugin_config, plugin_view, plugin_upgrade, skill_install, '
'skill_uninstall, create, update, delete, view, export, execute, debug, publish, member_invite, '
'member_role_update, member_remove, member_view, settings_update, settings_view, audit_log_view, '
'pipeline_extensions_update, knowledge_base_update, knowledge_base_delete, mcp_config, mcp_delete. '
'Combine resource_type and actor to answer "who installed X".',
False,
Permission.AUDIT_VIEW,
),
'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True, None),
'create_pipeline': (
CreatePipeline,
'Create an unconnected Pipeline draft. Requires user confirmation.',
True,
None,
),
'configure_pipeline': (
ConfigurePipeline,
'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.',
+96 -34
View File
@@ -360,6 +360,15 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='write',
resource_type='plugin',
),
# Uninstalling is a mutation with its own verb. Without it the DELETE on the
# shared ``/plugins/<author>/<name>`` route falls through to the read rule
# and an uninstall is mislabelled as a view.
ActionRule(
action='plugin_uninstall',
category='extension',
bucket='write',
resource_type='plugin',
),
ActionRule(
action='plugin_upgrade',
category='extension',
@@ -384,6 +393,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='write',
resource_type='skill',
),
ActionRule(
action='skill_uninstall',
category='extension',
bucket='write',
resource_type='skill',
),
# --- Knowledge & MCP -------------------------------------------------
ActionRule(
action='knowledge_base_view',
@@ -397,6 +412,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='write',
resource_type='knowledge_base',
),
ActionRule(
action='knowledge_base_delete',
category='knowledge',
bucket='write',
resource_type='knowledge_base',
),
ActionRule(
action='mcp_view',
category='integration',
@@ -409,6 +430,12 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='write',
resource_type='mcp_server',
),
ActionRule(
action='mcp_delete',
category='integration',
bucket='write',
resource_type='mcp_server',
),
# --- Pipelines -------------------------------------------------------
ActionRule(
action='pipeline_extensions_update',
@@ -471,6 +498,16 @@ ACTION_RULE_TABLE: typing.Final[tuple[ActionRule, ...]] = (
bucket='read',
resource_type='system',
),
# A management-assistant chat session is not a Workspace resource change:
# creating a conversation and sending a turn produce one opaque
# ``create/resource`` row each. They carry no "what changed" answer, so the
# whole family is dropped rather than cluttering the log with noise.
ActionRule(
action='assistant_session',
category='runtime',
bucket='skip',
resource_type='assistant_conversation',
),
)
ACTION_RULES_BY_ACTION: typing.Final[dict[str, ActionRule]] = {rule.action: rule for rule in ACTION_RULE_TABLE}
@@ -479,45 +516,57 @@ _READ_METHODS: typing.Final = frozenset({'GET', 'HEAD', 'OPTIONS'})
# Route rules evaluated in order; the first match wins, so the most specific
# rule must precede its prefix. Each rule is ``(fragments, read_action,
# write_action)``: every fragment must appear in the lowered route (a tuple
# expresses an AND, which lets ``/plugins/<author>/<name>/config`` be told
# apart from ``/plugins/<author>/<name>``), and the action is selected by
# whether the method is a read verb.
_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str], ...]] = (
# write_action, delete_action)``: every fragment must appear in the lowered
# route (a tuple expresses an AND, which lets ``/plugins/<author>/<name>/config``
# be told apart from ``/plugins/<author>/<name>``), and the action is selected by
# the method bucket -- read verb, ``DELETE``, or any other verb.
#
# ``delete_action`` is distinct because several resources register their read
# and their destroy on the *same* path (``GET``/``DELETE /plugins/<author>/<name>``,
# ``/knowledge/...``, ``/mcp/servers/<name>``). Keying only on fragments made a
# ``DELETE`` fall into the write bucket and record an uninstall as a view or an
# update, so the destructive operation left no readable trace.
_ROUTE_RULES: typing.Final[tuple[tuple[tuple[str, ...], str, str, str | None], ...]] = (
# --- Audit surface itself -------------------------------------------
(('/settings/operation-logs/export',), 'export', 'export'),
(('/settings/operation-logs',), 'audit_log_view', 'audit_log_view'),
(('/settings/operation-level',), 'settings_view', 'settings_update'),
(('/settings/governance',), 'settings_view', 'settings_update'),
(('/settings/limits',), 'settings_view', 'settings_update'),
(('/settings/operation-logs/export',), 'export', 'export', None),
(('/settings/operation-logs',), 'audit_log_view', 'audit_log_view', None),
(('/settings/operation-level',), 'settings_view', 'settings_update', None),
(('/settings/governance',), 'settings_view', 'settings_update', None),
(('/settings/limits',), 'settings_view', 'settings_update', None),
# --- Extension lifecycle: plugins -----------------------------------
(('/plugins/install',), 'plugin_view', 'plugin_install'),
(('/plugins/github',), 'plugin_view', 'plugin_view'),
(('/plugins/', '/config'), 'plugin_view', 'plugin_config'),
(('/plugins/', '/page-api'), 'page_view', 'page_view'),
(('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade'),
(('/plugins/', '/logs'), 'plugin_view', 'plugin_view'),
(('/plugins',), 'plugin_view', 'plugin_view'),
# ``/plugins/install`` is an install; the bare ``/plugins/<author>/<name>``
# is a read when fetched and an uninstall when deleted.
(('/plugins/install',), 'plugin_view', 'plugin_install', None),
(('/plugins/github',), 'plugin_view', 'plugin_view', None),
(('/plugins/', '/config'), 'plugin_view', 'plugin_config', None),
(('/plugins/', '/page-api'), 'page_view', 'page_view', None),
(('/plugins/', '/upgrade'), 'plugin_view', 'plugin_upgrade', None),
(('/plugins/', '/logs'), 'plugin_view', 'plugin_view', None),
(('/plugins',), 'plugin_view', 'plugin_view', 'plugin_uninstall'),
# The pipeline extension bindings (plugins / MCP servers / skills) live on
# ``/pipelines/<uuid>/extensions``, not on a plugin. Without this rule the
# generic fragment below would classify the change as a plugin config edit.
# The read keeps the generic ``view`` for backwards-compatible labelling.
(('/extensions',), 'view', 'pipeline_extensions_update'),
(('/extensions',), 'view', 'pipeline_extensions_update', None),
# --- Extension lifecycle: skills ------------------------------------
(('/skills/', '/install'), 'skill_view', 'skill_install'),
(('/skills',), 'skill_view', 'skill_view'),
(('/skills/', '/install'), 'skill_view', 'skill_install', None),
(('/skills',), 'skill_view', 'skill_view', 'skill_uninstall'),
# --- Knowledge bases & MCP servers ----------------------------------
(('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update'),
(('/mcp/', '/config'), 'mcp_view', 'mcp_config'),
(('/mcp',), 'mcp_view', 'mcp_config'),
(('/knowledge/',), 'knowledge_base_view', 'knowledge_base_update', 'knowledge_base_delete'),
(('/mcp/', '/config'), 'mcp_view', 'mcp_config', None),
(('/mcp',), 'mcp_view', 'mcp_config', 'mcp_delete'),
# --- Member management ----------------------------------------------
(('/members',), 'member_view', 'member_role_update'),
(('/invitations',), 'member_view', 'member_invite'),
(('/members',), 'member_view', 'member_role_update', None),
(('/invitations',), 'member_view', 'member_invite', None),
# --- Agent-assistant session chatter (never traced) ------------------
# Placed before the generic verbs so a conversation turn is not recorded as
# an opaque ``create`` on a nameless resource.
(('/assistant',), 'assistant_session', 'assistant_session', 'assistant_session'),
# --- Generic resource verbs -----------------------------------------
(('/export',), 'export', 'export'),
(('/debug',), 'debug', 'debug'),
(('/execute',), 'execute', 'execute'),
(('/publish',), 'publish', 'publish'),
(('/export',), 'export', 'export', None),
(('/debug',), 'debug', 'debug', None),
(('/execute',), 'execute', 'execute', None),
(('/publish',), 'publish', 'publish', None),
)
#: Refines the resource family for the generic verb rules. The route rules above
@@ -583,19 +632,28 @@ def classify(method: str, route: str) -> ActionRule:
upper_method = (method or 'GET').upper()
lowered_route = (route or '').lower()
is_read = upper_method in _READ_METHODS
is_delete = upper_method == 'DELETE'
for fragments, read_action, write_action in _ROUTE_RULES:
for fragments, read_action, write_action, delete_action in _ROUTE_RULES:
if all(fragment in lowered_route for fragment in fragments):
return _with_resource_type(ACTION_RULES_BY_ACTION[read_action if is_read else write_action], route)
if is_read:
action = read_action
elif is_delete:
# Fall back to the generic delete verb when a rule has no
# dedicated destroy action (still a mutation, never a view).
action = delete_action or 'delete'
else:
action = write_action
return _with_resource_type(ACTION_RULES_BY_ACTION[action], route)
if is_read:
return _with_resource_type(ACTION_RULES_BY_ACTION['view'], route)
if is_delete:
return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route)
if upper_method == 'POST':
return _with_resource_type(ACTION_RULES_BY_ACTION['create'], route)
if upper_method in {'PUT', 'PATCH'}:
return _with_resource_type(ACTION_RULES_BY_ACTION['update'], route)
if upper_method == 'DELETE':
return _with_resource_type(ACTION_RULES_BY_ACTION['delete'], route)
return _with_resource_type(ACTION_RULES_BY_ACTION['probe'], route)
@@ -620,9 +678,13 @@ def bucket_allows(bucket: str, effective_level: int) -> bool:
and ``audit`` are both *observation* buckets: viewing a resource and
viewing the audit surface itself are reads, so a mutation-level Workspace
must not fill its log with page views. They are only persisted once the
Workspace opts into the read level.
Workspace opts into the read level. ``skip`` is charter noise a Workspace
never needs traced (an assistant chat session's own turn traffic), so it is
dropped at every level instead of being mislabelled as a resource change.
"""
if bucket == 'skip':
return False
if effective_level <= OPERATION_LEVEL_NONE:
return False
if bucket in ('read', 'audit'):
+5
View File
@@ -2797,6 +2797,7 @@ const enUS = {
member: 'Member',
member_invitation: 'Member invitation',
operation_log: 'Operation log',
assistant_conversation: 'Assistant conversation',
plugin: 'Extension',
plugin_page: 'Extension page',
skill: 'Skill',
@@ -2822,14 +2823,18 @@ const enUS = {
plugin_view: 'View extension pages',
plugin_config: 'Change extension config',
plugin_install: 'Install extension',
plugin_uninstall: 'Uninstall extension',
plugin_upgrade: 'Upgrade extension',
page_view: 'View extension page',
skill_view: 'View skill',
skill_install: 'Install skill',
skill_uninstall: 'Uninstall skill',
knowledge_base_view: 'View knowledge base',
knowledge_base_update: 'Change knowledge base',
knowledge_base_delete: 'Delete knowledge base',
mcp_view: 'View MCP server',
mcp_config: 'Change MCP server',
mcp_delete: 'Delete MCP server',
pipeline_extensions_update: 'Update pipeline extension bindings',
export: 'Export data',
execute: 'Execute task',
+5
View File
@@ -2852,6 +2852,7 @@ const esES = {
member: 'Miembro',
member_invitation: 'Invitación de miembro',
operation_log: 'Registro de operaciones',
assistant_conversation: 'Conversación del asistente',
plugin: 'Extensión',
plugin_page: 'Página de extensión',
skill: 'Habilidad',
@@ -2877,14 +2878,18 @@ const esES = {
plugin_view: 'Ver páginas de extensión',
plugin_config: 'Cambiar configuración de extensión',
plugin_install: 'Instalar extensión',
plugin_uninstall: 'Desinstalar extensión',
plugin_upgrade: 'Actualizar extensión',
page_view: 'Ver página de extensión',
skill_view: 'Ver habilidad',
skill_install: 'Instalar habilidad',
skill_uninstall: 'Desinstalar habilidad',
knowledge_base_view: 'Ver base de conocimiento',
knowledge_base_update: 'Cambiar base de conocimiento',
knowledge_base_delete: 'Eliminar base de conocimiento',
mcp_view: 'Ver servidor MCP',
mcp_config: 'Cambiar servidor MCP',
mcp_delete: 'Eliminar servidor MCP',
pipeline_extensions_update:
'Actualizar vinculaciones de extensiones del pipeline',
export: 'Exportar datos',
+5
View File
@@ -2815,6 +2815,7 @@ const jaJP = {
member: 'メンバー',
member_invitation: 'メンバー招待',
operation_log: '操作ログ',
assistant_conversation: 'アシスタント会話',
plugin: '拡張機能',
plugin_page: '拡張ページ',
skill: 'スキル',
@@ -2840,14 +2841,18 @@ const jaJP = {
plugin_view: '拡張ページを閲覧',
plugin_config: '拡張設定を変更',
plugin_install: '拡張をインストール',
plugin_uninstall: '拡張をアンインストール',
plugin_upgrade: '拡張をアップグレード',
page_view: '拡張ページを閲覧',
skill_view: 'スキルを閲覧',
skill_install: 'スキルをインストール',
skill_uninstall: 'スキルをアンインストール',
knowledge_base_view: 'ナレッジベースを閲覧',
knowledge_base_update: 'ナレッジベースを変更',
knowledge_base_delete: 'ナレッジベースを削除',
mcp_view: 'MCP サーバーを閲覧',
mcp_config: 'MCP サーバーを変更',
mcp_delete: 'MCP サーバーを削除',
pipeline_extensions_update: 'パイプライン拡張の紐付けを変更',
export: 'データをエクスポート',
execute: 'タスクを実行',
+5
View File
@@ -2822,6 +2822,7 @@ const ruRU = {
member: 'Участник',
member_invitation: 'Приглашение участника',
operation_log: 'Журнал операций',
assistant_conversation: 'Сессия ассистента',
plugin: 'Расширение',
plugin_page: 'Страница расширения',
skill: 'Навык',
@@ -2847,14 +2848,18 @@ const ruRU = {
plugin_view: 'Просмотр страниц расширения',
plugin_config: 'Изменение настроек расширения',
plugin_install: 'Установка расширения',
plugin_uninstall: 'Удаление расширения',
plugin_upgrade: 'Обновление расширения',
page_view: 'Просмотр страницы расширения',
skill_view: 'Просмотр навыка',
skill_install: 'Установка навыка',
skill_uninstall: 'Удаление навыка',
knowledge_base_view: 'Просмотр базы знаний',
knowledge_base_update: 'Изменение базы знаний',
knowledge_base_delete: 'Удаление базы знаний',
mcp_view: 'Просмотр сервера MCP',
mcp_config: 'Изменение сервера MCP',
mcp_delete: 'Удаление сервера MCP',
pipeline_extensions_update: 'Изменение привязок расширений конвейера',
export: 'Экспорт данных',
execute: 'Выполнение задачи',
+5
View File
@@ -2749,6 +2749,7 @@ const thTH = {
member: 'สมาชิก',
member_invitation: 'คำเชิญสมาชิก',
operation_log: 'บันทึกการดำเนินการ',
assistant_conversation: 'บทสนทนาผู้ช่วย',
plugin: 'ส่วนขยาย',
plugin_page: 'หน้าส่วนขยาย',
skill: 'ทักษะ',
@@ -2774,14 +2775,18 @@ const thTH = {
plugin_view: 'ดูหน้าส่วนขยาย',
plugin_config: 'แก้ไขการตั้งค่าส่วนขยาย',
plugin_install: 'ติดตั้งส่วนขยาย',
plugin_uninstall: 'ถอนการติดตั้งส่วนขยาย',
plugin_upgrade: 'อัปเกรดส่วนขยาย',
page_view: 'ดูหน้าส่วนขยาย',
skill_view: 'ดูทักษะ',
skill_install: 'ติดตั้งทักษะ',
skill_uninstall: 'ถอนการติดตั้งทักษะ',
knowledge_base_view: 'ดูฐานความรู้',
knowledge_base_update: 'แก้ไขฐานความรู้',
knowledge_base_delete: 'ลบฐานความรู้',
mcp_view: 'ดูเซิร์ฟเวอร์ MCP',
mcp_config: 'แก้ไขเซิร์ฟเวอร์ MCP',
mcp_delete: 'ลบเซิร์ฟเวอร์ MCP',
pipeline_extensions_update: 'แก้ไขการผูกส่วนขยายของไปป์ไลน์',
export: 'ส่งออกข้อมูล',
execute: 'รันงาน',
+5
View File
@@ -2784,6 +2784,7 @@ const viVN = {
member: 'Thành viên',
member_invitation: 'Lời mời thành viên',
operation_log: 'Nhật ký thao tác',
assistant_conversation: 'Phiên trợ lý',
plugin: 'Tiện ích mở rộng',
plugin_page: 'Trang tiện ích',
skill: 'Kỹ năng',
@@ -2809,14 +2810,18 @@ const viVN = {
plugin_view: 'Xem trang tiện ích',
plugin_config: 'Thay đổi cấu hình tiện ích',
plugin_install: 'Cài đặt tiện ích',
plugin_uninstall: 'Gỡ cài đặt tiện ích',
plugin_upgrade: 'Nâng cấp tiện ích',
page_view: 'Xem trang tiện ích',
skill_view: 'Xem kỹ năng',
skill_install: 'Cài đặt kỹ năng',
skill_uninstall: 'Gỡ cài đặt kỹ năng',
knowledge_base_view: 'Xem cơ sở tri thức',
knowledge_base_update: 'Thay đổi cơ sở tri thức',
knowledge_base_delete: 'Xóa cơ sở tri thức',
mcp_view: 'Xem máy chủ MCP',
mcp_config: 'Thay đổi máy chủ MCP',
mcp_delete: 'Xóa máy chủ MCP',
pipeline_extensions_update: 'Cập nhật liên kết tiện ích của pipeline',
export: 'Xuất dữ liệu',
execute: 'Chạy tác vụ',
+5
View File
@@ -2647,6 +2647,7 @@ const zhHans = {
member: '成员',
member_invitation: '成员邀请',
operation_log: '操作日志',
assistant_conversation: '助手会话',
plugin: '扩展',
plugin_page: '扩展页面',
skill: '技能',
@@ -2672,14 +2673,18 @@ const zhHans = {
plugin_view: '查看扩展页面',
plugin_config: '修改扩展配置',
plugin_install: '安装扩展',
plugin_uninstall: '卸载扩展',
plugin_upgrade: '升级扩展',
page_view: '查看扩展页面',
skill_view: '查看技能',
skill_install: '安装技能',
skill_uninstall: '卸载技能',
knowledge_base_view: '查看知识库',
knowledge_base_update: '修改知识库',
knowledge_base_delete: '删除知识库',
mcp_view: '查看 MCP 服务器',
mcp_config: '修改 MCP 服务器',
mcp_delete: '删除 MCP 服务器',
pipeline_extensions_update: '修改管道扩展绑定',
export: '导出数据',
execute: '执行任务',
+5
View File
@@ -2648,6 +2648,7 @@ const zhHant = {
member: '成員',
member_invitation: '成員邀請',
operation_log: '操作日誌',
assistant_conversation: '助手會話',
plugin: '擴充',
plugin_page: '擴充頁面',
skill: '技能',
@@ -2673,14 +2674,18 @@ const zhHant = {
plugin_view: '查看擴充頁面',
plugin_config: '修改擴充設定',
plugin_install: '安裝擴充',
plugin_uninstall: '解除安裝擴充',
plugin_upgrade: '升級擴充',
page_view: '查看擴充頁面',
skill_view: '查看技能',
skill_install: '安裝技能',
skill_uninstall: '解除安裝技能',
knowledge_base_view: '查看知識庫',
knowledge_base_update: '修改知識庫',
knowledge_base_delete: '刪除知識庫',
mcp_view: '查看 MCP 伺服器',
mcp_config: '修改 MCP 伺服器',
mcp_delete: '刪除 MCP 伺服器',
pipeline_extensions_update: '修改管道擴展綁定',
export: '匯出資料',
execute: '執行任務',