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'):