unique-sdk 2026.42.0.dev2__tar.gz → 2026.42.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/PKG-INFO +1 -1
  2. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/pyproject.toml +2 -1
  3. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_agentic_table.py +67 -1
  4. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_content.py +1 -0
  5. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/files.py +11 -0
  6. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/folders.py +11 -2
  7. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/navigation.py +20 -2
  8. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/read.py +3 -0
  9. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/search.py +1 -0
  10. unique_sdk-2026.42.0.dev4/unique_sdk/cli/skill_guard.py +314 -0
  11. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +1 -0
  12. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/state.py +42 -0
  13. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/README.md +0 -0
  14. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/__init__.py +0 -0
  15. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_api_requestor.py +0 -0
  16. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_api_resource.py +0 -0
  17. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_api_version.py +0 -0
  18. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_error.py +0 -0
  19. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_http_client.py +0 -0
  20. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_list_object.py +0 -0
  21. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_object_classes.py +0 -0
  22. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_request_options.py +0 -0
  23. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_unique_object.py +0 -0
  24. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_unique_ql.py +0 -0
  25. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_unique_response.py +0 -0
  26. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_util.py +0 -0
  27. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_version.py +0 -0
  28. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/_webhook.py +0 -0
  29. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/__init__.py +0 -0
  30. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_acronyms.py +0 -0
  31. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_analytics_order.py +0 -0
  32. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_benchmarking.py +0 -0
  33. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_briefing.py +0 -0
  34. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_chat_completion.py +0 -0
  35. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_context_memory.py +0 -0
  36. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_elicitation.py +0 -0
  37. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_embedding.py +0 -0
  38. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_event.py +0 -0
  39. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_folder.py +0 -0
  40. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_group.py +0 -0
  41. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_integrated.py +0 -0
  42. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_llm_models.py +0 -0
  43. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_mcp.py +0 -0
  44. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_message.py +0 -0
  45. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_message_assessment.py +0 -0
  46. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_message_execution.py +0 -0
  47. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_message_log.py +0 -0
  48. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_message_tool.py +0 -0
  49. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_module.py +0 -0
  50. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  51. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_search.py +0 -0
  52. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_search_string.py +0 -0
  53. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  54. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_space.py +0 -0
  55. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_user.py +0 -0
  56. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/api_resources/_web_search.py +0 -0
  57. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/__init__.py +0 -0
  58. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/__main__.py +0 -0
  59. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/cli.py +0 -0
  60. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/__init__.py +0 -0
  61. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  62. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/agentic_table.py +0 -0
  63. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  64. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/browser.py +0 -0
  65. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/cite_file.py +0 -0
  66. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/elicitation.py +0 -0
  67. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/mcp.py +0 -0
  68. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  69. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/subagent.py +0 -0
  70. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/web_search.py +0 -0
  71. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/commands/web_search_config.py +0 -0
  72. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/config.py +0 -0
  73. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/formatting.py +0 -0
  74. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/identity.py +0 -0
  75. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/metadata_filter.py +0 -0
  76. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/shell.py +0 -0
  77. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  78. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  79. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  82. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  83. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  84. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  85. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.42.0.dev2 → unique_sdk-2026.42.0.dev4}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.42.0.dev2
3
+ Version: 2026.42.0.dev4
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.42.0.dev2"
3
+ version = "2026.42.0.dev4"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -91,6 +91,7 @@ markers = [
91
91
  "asyncio: asyncio tests",
92
92
  "integration: integration tests that require API access or credentials",
93
93
  "serial: tests that must run serially",
94
+ "skill_guard: CLI tests that exercise the real skill guard",
94
95
  "unit: unit tests",
95
96
  "verified: AI-generated tests with human verification",
96
97
  ]
@@ -348,6 +348,19 @@ class AgenticTable(APIResource["AgenticTable"]):
348
348
  rowId: str
349
349
  entries: list[MagicTableMetadataEntry]
350
350
 
351
+ class UpdateRowMetadata(RequestOptions):
352
+ """Body for `POST /magic-table/{tableId}/row/metadata/{metadataId}` (update one row metadata entry).
353
+
354
+ The entry is addressed by its own id (``id`` on a ``rowMetadata`` entry),
355
+ not by row or key.
356
+ """
357
+
358
+ tableId: str
359
+ metadataId: str
360
+ key: str
361
+ value: str
362
+ exactFilter: NotRequired[bool]
363
+
351
364
  @classmethod
352
365
  async def set_cell(
353
366
  cls,
@@ -505,10 +518,19 @@ class AgenticTable(APIResource["AgenticTable"]):
505
518
  company_id: str,
506
519
  tableId: str,
507
520
  cells: list[AgenticTableCell],
521
+ overwriteLibraryRows: bool | None = None,
508
522
  ) -> ColumnMetadataUpdateStatus:
523
+ """Upsert cells (`POST /magic-table/{tableId}/cells/bulk-upsert`).
524
+
525
+ ``overwriteLibraryRows=True`` asks the server to apply updates to existing
526
+ cells on library-sheet rows, which it otherwise drops for assistant
527
+ writes. The server only honors it on library sheets, for rows where the
528
+ caller can read every sheet named in the row's ``sourceSheetId`` metadata.
529
+ ``None`` omits the field; servers that predate it reject it with a 400.
530
+ """
509
531
  url = f"/magic-table/{tableId}/cells/bulk-upsert"
510
532
  try:
511
- params_api = {
533
+ params_api: dict[str, Any] = {
512
534
  "cells": [
513
535
  {
514
536
  "rowOrder": cell["rowOrder"],
@@ -520,6 +542,8 @@ class AgenticTable(APIResource["AgenticTable"]):
520
542
  }
521
543
  except Exception as e:
522
544
  raise ValueError(f"Invalid data or missing required fields: {e}")
545
+ if overwriteLibraryRows is not None:
546
+ params_api["overwriteLibraryRows"] = overwriteLibraryRows
523
547
  return cast(
524
548
  "ColumnMetadataUpdateStatus",
525
549
  await cls._static_request_async(
@@ -720,6 +744,48 @@ class AgenticTable(APIResource["AgenticTable"]):
720
744
  ),
721
745
  )
722
746
 
747
+ @classmethod
748
+ async def update_row_metadata(
749
+ cls,
750
+ user_id: str,
751
+ company_id: str,
752
+ **params: Unpack["AgenticTable.UpdateRowMetadata"],
753
+ ) -> MagicTableActionResult:
754
+ """Update one row metadata entry (`POST /magic-table/{tableId}/row/metadata/{metadataId}`)."""
755
+ url = f"/magic-table/{params['tableId']}/row/metadata/{params['metadataId']}"
756
+ params.pop("tableId")
757
+ params.pop("metadataId")
758
+ return cast(
759
+ MagicTableActionResult,
760
+ await cls._static_request_async(
761
+ "post",
762
+ url,
763
+ user_id,
764
+ company_id,
765
+ params,
766
+ ),
767
+ )
768
+
769
+ @classmethod
770
+ async def delete_row_metadata(
771
+ cls,
772
+ user_id: str,
773
+ company_id: str,
774
+ tableId: str,
775
+ metadataId: str,
776
+ ) -> MagicTableActionResult:
777
+ """Delete one row metadata entry (`DELETE /magic-table/{tableId}/row/metadata/{metadataId}`)."""
778
+ url = f"/magic-table/{tableId}/row/metadata/{metadataId}"
779
+ return cast(
780
+ MagicTableActionResult,
781
+ await cls._static_request_async(
782
+ "delete",
783
+ url,
784
+ user_id,
785
+ company_id,
786
+ ),
787
+ )
788
+
723
789
  @classmethod
724
790
  async def delete_sheet_metadata(
725
791
  cls,
@@ -21,6 +21,7 @@ class Content(APIResource["Content"]):
21
21
 
22
22
  id: str
23
23
  key: str
24
+ ownerId: str
24
25
  url: str | None
25
26
  title: str | None
26
27
  updatedAt: str
@@ -11,6 +11,7 @@ from typing import Any
11
11
 
12
12
  import unique_sdk
13
13
  from unique_sdk.cli.formatting import format_content_info
14
+ from unique_sdk.cli.skill_guard import skill_denial
14
15
  from unique_sdk.cli.state import ShellState
15
16
  from unique_sdk.utils.file_io import download_content, upload_file
16
17
 
@@ -337,6 +338,10 @@ def cmd_upload(
337
338
  "upload: permission denied: destination is outside your "
338
339
  f"task scope ({state.scope_denial_hint()})."
339
340
  )
341
+ if state.skill_guard.is_folder_write_denied(
342
+ scope_id, new_file_name=display_name
343
+ ):
344
+ return skill_denial("upload", display_name)
340
345
 
341
346
  mime_type = _detect_upload_mime_type(path)
342
347
 
@@ -426,6 +431,8 @@ def cmd_download(
426
431
  """Download a file by name or content ID."""
427
432
  try:
428
433
  content_id, display_name = _resolve_content_id(state, name_or_id)
434
+ if state.is_skill_content_read_denied(content_id):
435
+ return skill_denial("download", display_name)
429
436
 
430
437
  raw_name = (
431
438
  display_name if not display_name.startswith("cont_") else f"{content_id}"
@@ -462,6 +469,8 @@ def cmd_rm(state: ShellState, name_or_id: str) -> str:
462
469
  content_id, display_name = _resolve_content_id(
463
470
  state, name_or_id, allow_chat_files=False
464
471
  )
472
+ if state.is_skill_content_write_denied(content_id):
473
+ return skill_denial("rm", display_name)
465
474
  unique_sdk.Content.delete(
466
475
  user_id=state.config.user_id,
467
476
  company_id=state.config.company_id,
@@ -481,6 +490,8 @@ def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
481
490
  content_id, display_name = _resolve_content_id(
482
491
  state, old_name, allow_chat_files=False
483
492
  )
493
+ if state.is_skill_content_write_denied(content_id, new_name=new_name):
494
+ return skill_denial("mv", display_name)
484
495
  result = unique_sdk.Content.update(
485
496
  user_id=state.config.user_id,
486
497
  company_id=state.config.company_id,
@@ -6,6 +6,7 @@ import os
6
6
 
7
7
  import unique_sdk
8
8
  from unique_sdk.cli.formatting import format_folder_info
9
+ from unique_sdk.cli.skill_guard import skill_denial
9
10
  from unique_sdk.cli.state import ShellState
10
11
 
11
12
 
@@ -59,6 +60,8 @@ def cmd_mkdir(state: ShellState, name: str) -> str:
59
60
  "mkdir: permission denied: destination is outside your task scope "
60
61
  f"({state.scope_denial_hint()})."
61
62
  )
63
+ if state.skill_guard.is_path_write_denied(full_path):
64
+ return skill_denial("mkdir", full_path)
62
65
  try:
63
66
  result = unique_sdk.Folder.create_paths(
64
67
  user_id=state.config.user_id,
@@ -84,11 +87,14 @@ def cmd_rmdir(state: ShellState, target: str, recursive: bool = False) -> str:
84
87
  and not state.is_folder_target_within_workspace(target)
85
88
  ):
86
89
  return "rmdir: permission denied (outside workspace scope)"
87
- if _metadata_filter_denies_folder(state, _resolve_folder_scope_id(state, target)):
90
+ scope_id = _resolve_folder_scope_id(state, target)
91
+ if _metadata_filter_denies_folder(state, scope_id):
88
92
  return (
89
93
  "rmdir: permission denied: target is outside your task scope "
90
94
  f"({state.scope_denial_hint()})."
91
95
  )
96
+ if state.skill_guard.is_folder_write_denied(scope_id, include_subtree=True):
97
+ return skill_denial("rmdir", target)
92
98
  try:
93
99
  if target.startswith("scope_"):
94
100
  unique_sdk.Folder.delete(
@@ -122,11 +128,14 @@ def cmd_mvdir(state: ShellState, old_name: str, new_name: str) -> str:
122
128
  and not state.is_folder_target_within_workspace(old_name)
123
129
  ):
124
130
  return "mvdir: permission denied (outside workspace scope)"
125
- if _metadata_filter_denies_folder(state, _resolve_folder_scope_id(state, old_name)):
131
+ scope_id = _resolve_folder_scope_id(state, old_name)
132
+ if _metadata_filter_denies_folder(state, scope_id):
126
133
  return (
127
134
  "mvdir: permission denied: target is outside your task scope "
128
135
  f"({state.scope_denial_hint()})."
129
136
  )
137
+ if state.skill_guard.is_folder_write_denied(scope_id, include_subtree=True):
138
+ return skill_denial("mvdir", old_name)
130
139
  try:
131
140
  if old_name.startswith("scope_"):
132
141
  result = unique_sdk.Folder.update(
@@ -7,6 +7,7 @@ from typing import Any
7
7
  import unique_sdk
8
8
  from unique_sdk.cli.formatting import format_ls
9
9
  from unique_sdk.cli.metadata_filter import _collect_filter_targets
10
+ from unique_sdk.cli.skill_guard import skill_denial
10
11
  from unique_sdk.cli.state import ShellState
11
12
 
12
13
 
@@ -43,6 +44,8 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
43
44
  f"ls: permission denied: target is outside your task scope "
44
45
  f"({state.scope_denial_hint()})."
45
46
  )
47
+ if scope_id is not None and state.skill_guard.is_folder_hidden(scope_id):
48
+ return skill_denial("ls", target or state.cwd)
46
49
 
47
50
  # At root with a per-message KB scope (e.g. an Agentic Table column's
48
51
  # scope_rules), show only the in-scope folders and explicitly-scoped
@@ -67,6 +70,11 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
67
70
  )
68
71
  except unique_sdk.UniqueError:
69
72
  pass
73
+ scoped_folders = [
74
+ folder
75
+ for folder in scoped_folders
76
+ if not state.skill_guard.is_folder_hidden(folder.get("id", ""))
77
+ ]
70
78
  scoped_files: list[Any] = []
71
79
  for cid in content_ids:
72
80
  # A contentId mentioned in the filter is not necessarily in
@@ -77,6 +85,8 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
77
85
  # UN-21780.
78
86
  if not state.is_content_within_workspace(cid):
79
87
  continue
88
+ if state.is_skill_content_read_denied(cid):
89
+ continue
80
90
  try:
81
91
  info = unique_sdk.Content.get_info(
82
92
  user_id=state.config.user_id,
@@ -109,6 +119,11 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
109
119
  folders.append(info)
110
120
  except unique_sdk.UniqueError:
111
121
  pass
122
+ folders = [
123
+ folder
124
+ for folder in folders
125
+ if not state.skill_guard.is_folder_hidden(folder.get("id", ""))
126
+ ]
112
127
  output = format_ls(folders, [])
113
128
  summary = f"\n{len(folders)} folder(s), 0 file(s)"
114
129
  return output + summary
@@ -124,7 +139,8 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
124
139
  company_id=state.config.company_id,
125
140
  **folder_params,
126
141
  )
127
- folders = folder_result.get("folderInfos", [])
142
+ listed_folders = folder_result.get("folderInfos", [])
143
+ folders = state.skill_guard.visible_child_folders(listed_folders)
128
144
 
129
145
  content_result = unique_sdk.Content.get_infos(
130
146
  user_id=state.config.user_id,
@@ -133,7 +149,9 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
133
149
  )
134
150
  files = content_result.get("contentInfos", [])
135
151
 
136
- total_folders = folder_result.get("totalCount", len(folders))
152
+ total_folders = folder_result.get("totalCount", len(listed_folders)) - (
153
+ len(listed_folders) - len(folders)
154
+ )
137
155
  total_files = content_result.get("totalCount", len(files))
138
156
 
139
157
  # With a per-message filter, listing inside an allowed folder must not
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
  from typing import Any
19
19
 
20
20
  import unique_sdk
21
+ from unique_sdk.cli.skill_guard import skill_denial
21
22
  from unique_sdk.cli.state import ShellState
22
23
 
23
24
  READ_ERROR_PREFIX = "read:"
@@ -103,6 +104,8 @@ def cmd_read(
103
104
  f"task scope ({state.scope_denial_hint()}). Use 'unique-cli search' "
104
105
  "or 'ls' within that scope instead."
105
106
  )
107
+ if state.is_skill_content_read_denied(cont_id):
108
+ return skill_denial("read", cont_id)
106
109
 
107
110
  try:
108
111
  results = unique_sdk.Content.search(
@@ -280,6 +280,7 @@ def cmd_search(
280
280
  company_id=state.config.company_id,
281
281
  **search_params,
282
282
  )
283
+ results = [hit for hit in results if not state.is_skill_search_hit_hidden(hit)]
283
284
 
284
285
  except (ValueError, unique_sdk.UniqueError) as e:
285
286
  return f"{SEARCH_ERROR_PREFIX} {e}"
@@ -0,0 +1,314 @@
1
+ """Keep CLI reads and writes out of skill folders (a ``skill.md``/``skills.md`` marker
2
+ at or above the folder) outside the caller's personal skills
3
+ (``<home>/skills-conduct/space-<spaceId>``)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ import unique_sdk
14
+ from unique_sdk.cli.config import Config
15
+
16
+ _MARKER_NAME_RE = re.compile(r"^skills?\.md$", re.IGNORECASE)
17
+
18
+ # The SDK JSON-encodes request params without an Enum encoder, so
19
+ # ``mode: insensitive`` can't be sent. Match the common spellings instead and
20
+ # re-check every hit with ``is_skill_marker_name``.
21
+ _MARKER_KEY_SUFFIXES = tuple(
22
+ f"{stem}{extension}"
23
+ for stem in ("skill", "Skill", "SKILL", "skills", "Skills", "SKILLS")
24
+ for extension in (".md", ".MD")
25
+ )
26
+
27
+ _FOLDER_ID_PATH_PREFIX = "uniquepathid://"
28
+ _MAX_FOLDER_DEPTH = 64
29
+
30
+
31
+ class PersonalSkillsFolder(StrEnum):
32
+ """Folder names in ``/home/<UserName>/skills-conduct/space-<spaceId>``."""
33
+
34
+ HOME_ROOT = "home"
35
+ LEGACY_HOME_PREFIX = "home-"
36
+ SKILLS = "skills-conduct"
37
+ SPACE_PREFIX = "space-"
38
+
39
+
40
+ def is_skill_marker_name(name: str) -> bool:
41
+ """True when the last segment of *name* is a skill marker file name."""
42
+ base = name.replace("\\", "/").rsplit("/", 1)[-1].strip()
43
+ return bool(_MARKER_NAME_RE.match(base))
44
+
45
+
46
+ def skill_denial(command: str, target: str) -> str:
47
+ """Denial text in the ``<command>: permission denied`` shape the CLI exits on."""
48
+ return (
49
+ f"{command}: permission denied: {target} is part of a skill outside your "
50
+ "personal skills, or its skill status could not be checked."
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class _FolderNode:
56
+ scope_id: str
57
+ name: str
58
+ parent_id: str | None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class _SkillMarker:
63
+ skill_folder_id: str
64
+ # Empty when the content has no ``folderIdPath``; looked up on demand.
65
+ folder_ids: frozenset[str]
66
+
67
+
68
+ class SkillGuard:
69
+ """Answers whether a read or write would touch a protected skill."""
70
+
71
+ def __init__(self, config: Config) -> None:
72
+ self._config = config
73
+ self._folders: dict[str, _FolderNode] = {}
74
+ self._markers: tuple[_SkillMarker, ...] | None = None
75
+ self._hidden: dict[str, bool] = {}
76
+
77
+ def is_folder_hidden(self, scope_id: str) -> bool:
78
+ """True when *scope_id* is inside a protected skill. Fails closed."""
79
+ cached = self._hidden.get(scope_id)
80
+ if cached is not None:
81
+ return cached
82
+ try:
83
+ chain = self._folder_chain(scope_id)
84
+ hidden = not self._is_in_own_personal_layer(chain) and self._has_marker_in(
85
+ [node.scope_id for node in chain]
86
+ )
87
+ except unique_sdk.UniqueError:
88
+ hidden = True
89
+ self._hidden[scope_id] = hidden
90
+ return hidden
91
+
92
+ def is_folder_path_hidden(self, folder_ids: Sequence[str]) -> bool:
93
+ """True when a ``folderIdPath`` runs through a protected skill. Fails closed."""
94
+ try:
95
+ skill_folder_ids = {
96
+ marker.skill_folder_id for marker in self._all_skill_markers()
97
+ }
98
+ except unique_sdk.UniqueError:
99
+ return True
100
+ return any(
101
+ self.is_folder_hidden(folder_id)
102
+ for folder_id in folder_ids
103
+ if folder_id in skill_folder_ids
104
+ )
105
+
106
+ def visible_child_folders(self, folders: Sequence[Any]) -> list[Any]:
107
+ """Drop protected skills from the child folders of a visible folder."""
108
+ folder_ids = [folder.get("id") for folder in folders if folder.get("id")]
109
+ if not folder_ids:
110
+ return list(folders)
111
+ skill_folder_ids = {
112
+ _marker_owner_id(content)
113
+ for content in self._search_markers({"ownerId": {"in_": folder_ids}})
114
+ if is_skill_marker_name(content.get("key") or "")
115
+ }
116
+ return [
117
+ folder
118
+ for folder in folders
119
+ if folder.get("id") not in skill_folder_ids
120
+ or not self.is_folder_hidden(folder["id"])
121
+ ]
122
+
123
+ def is_folder_write_denied(
124
+ self,
125
+ scope_id: str | None,
126
+ *,
127
+ include_subtree: bool = False,
128
+ new_file_name: str | None = None,
129
+ ) -> bool:
130
+ """True when writing in *scope_id* would change a protected skill.
131
+
132
+ ``include_subtree`` also protects skills below the folder (``rmdir``,
133
+ ``mvdir``). ``new_file_name`` protects against creating a marker, which
134
+ would turn the folder into a skill. Fails closed when the folder is
135
+ unknown or a lookup fails.
136
+ """
137
+ if not scope_id:
138
+ return True
139
+ try:
140
+ return self._is_folder_write_denied(
141
+ scope_id,
142
+ include_subtree=include_subtree,
143
+ new_file_name=new_file_name,
144
+ )
145
+ except unique_sdk.UniqueError:
146
+ return True
147
+
148
+ def is_path_write_denied(self, folder_path: str) -> bool:
149
+ """True when creating *folder_path* would add structure inside a protected skill.
150
+
151
+ Checks the deepest existing folder on the path, because the target does
152
+ not exist yet.
153
+ """
154
+ segments = [segment for segment in folder_path.split("/") if segment]
155
+ while segments:
156
+ candidate = "/" + "/".join(segments)
157
+ try:
158
+ info = unique_sdk.Folder.get_info(
159
+ user_id=self._config.user_id,
160
+ company_id=self._config.company_id,
161
+ folderPath=candidate,
162
+ )
163
+ except unique_sdk.UniqueError:
164
+ segments.pop()
165
+ continue
166
+ scope_id = info.get("id")
167
+ if not scope_id:
168
+ return True
169
+ return self.is_folder_write_denied(scope_id)
170
+ return False
171
+
172
+ def _is_folder_write_denied(
173
+ self,
174
+ scope_id: str,
175
+ *,
176
+ include_subtree: bool,
177
+ new_file_name: str | None,
178
+ ) -> bool:
179
+ chain = self._folder_chain(scope_id)
180
+ if self._is_in_own_personal_layer(chain):
181
+ return False
182
+ if new_file_name is not None and is_skill_marker_name(new_file_name):
183
+ return True
184
+ if self._has_marker_in([node.scope_id for node in chain]):
185
+ return True
186
+ if include_subtree:
187
+ return any(
188
+ scope_id in self._marker_folder_ids(marker)
189
+ for marker in self._all_skill_markers()
190
+ )
191
+ return False
192
+
193
+ def _is_in_own_personal_layer(self, chain: list[_FolderNode]) -> bool:
194
+ """True inside ``<home>/skills-conduct/space-<spaceId>``.
195
+
196
+ The API resolves no other user's home for the caller, so a home in the
197
+ chain is the caller's own. The API re-checks the owner on write.
198
+ """
199
+ return any(
200
+ node.name.startswith(PersonalSkillsFolder.SPACE_PREFIX)
201
+ and len(chain) > index + 2
202
+ and chain[index + 1].name == PersonalSkillsFolder.SKILLS
203
+ and self._is_own_home(chain[index + 2 :])
204
+ for index, node in enumerate(chain)
205
+ )
206
+
207
+ def _is_own_home(self, chain_from_home: list[_FolderNode]) -> bool:
208
+ """``/home/<UserName>``, or the legacy root ``/home-<userId>``."""
209
+ home, *above = chain_from_home
210
+ if not above:
211
+ return (
212
+ home.name
213
+ == f"{PersonalSkillsFolder.LEGACY_HOME_PREFIX}{self._config.user_id}"
214
+ )
215
+ return (
216
+ len(above) == 1
217
+ and above[0].name == PersonalSkillsFolder.HOME_ROOT
218
+ and above[0].parent_id is None
219
+ )
220
+
221
+ def _has_marker_in(self, folder_ids: list[str]) -> bool:
222
+ if not folder_ids:
223
+ return False
224
+ results = self._search_markers({"ownerId": {"in_": folder_ids}})
225
+ return any(
226
+ is_skill_marker_name(content.get("key") or "") for content in results
227
+ )
228
+
229
+ def _marker_folder_ids(self, marker: _SkillMarker) -> frozenset[str]:
230
+ if marker.folder_ids:
231
+ return marker.folder_ids
232
+ return frozenset(
233
+ node.scope_id for node in self._folder_chain(marker.skill_folder_id)
234
+ )
235
+
236
+ def _folder_chain(self, scope_id: str) -> list[_FolderNode]:
237
+ """The folder and its ancestors, nearest first."""
238
+ chain: list[_FolderNode] = []
239
+ current: str | None = scope_id
240
+ while current and len(chain) < _MAX_FOLDER_DEPTH:
241
+ node = self._folder(current)
242
+ chain.append(node)
243
+ current = node.parent_id
244
+ return chain
245
+
246
+ def _folder(self, scope_id: str) -> _FolderNode:
247
+ cached = self._folders.get(scope_id)
248
+ if cached is not None:
249
+ return cached
250
+ info = unique_sdk.Folder.get_info(
251
+ user_id=self._config.user_id,
252
+ company_id=self._config.company_id,
253
+ scopeId=scope_id,
254
+ )
255
+ node = _FolderNode(
256
+ scope_id=info["id"],
257
+ name=info.get("name") or "",
258
+ parent_id=info.get("parentId"),
259
+ )
260
+ self._folders[scope_id] = node
261
+ return node
262
+
263
+ def _search_markers(
264
+ self, where: unique_sdk.Content.ContentWhereInput | None = None
265
+ ) -> list[unique_sdk.Content]:
266
+ key_filter: unique_sdk.Content.ContentWhereInput = {
267
+ "OR": [{"key": {"endsWith": suffix}} for suffix in _MARKER_KEY_SUFFIXES]
268
+ }
269
+ clauses = [key_filter] if where is None else [key_filter, where]
270
+ return unique_sdk.Content.search(
271
+ user_id=self._config.user_id,
272
+ company_id=self._config.company_id,
273
+ where={"AND": clauses},
274
+ )
275
+
276
+ def _all_skill_markers(self) -> tuple[_SkillMarker, ...]:
277
+ """Every marker the caller can read. Only subtree checks need this."""
278
+ if self._markers is not None:
279
+ return self._markers
280
+ markers: list[_SkillMarker] = []
281
+ for content in self._search_markers():
282
+ if not is_skill_marker_name(content.get("key") or ""):
283
+ continue
284
+ path_ids = folder_ids_from_metadata(content.get("metadata"))
285
+ owner_id = _marker_owner_id(content)
286
+ if not owner_id.startswith("scope_"):
287
+ continue
288
+ markers.append(
289
+ _SkillMarker(
290
+ skill_folder_id=owner_id,
291
+ folder_ids=frozenset({owner_id, *path_ids})
292
+ if path_ids
293
+ else frozenset(),
294
+ )
295
+ )
296
+ self._markers = tuple(markers)
297
+ return self._markers
298
+
299
+
300
+ def _marker_owner_id(content: unique_sdk.Content) -> str:
301
+ """The marker's folder: ``ownerId``, else the last ``folderIdPath`` segment."""
302
+ path_ids = folder_ids_from_metadata(content.get("metadata"))
303
+ return content.get("ownerId") or (path_ids[-1] if path_ids else "")
304
+
305
+
306
+ def folder_ids_from_metadata(metadata: object) -> list[str]:
307
+ """Folder ids in a content's ``folderIdPath``, or ``[]`` when absent."""
308
+ if not isinstance(metadata, dict):
309
+ return []
310
+ folder_id_path = metadata.get("folderIdPath")
311
+ if not isinstance(folder_id_path, str):
312
+ return []
313
+ path = folder_id_path.removeprefix(_FOLDER_ID_PATH_PREFIX)
314
+ return [segment for segment in path.split("/") if segment.startswith("scope_")]
@@ -288,6 +288,7 @@ verified page numbers (if any) and `--read-method`.
288
288
  - File-not-found and folder-not-found errors are returned as text, not exceptions.
289
289
  - Successful results print to stdout -- parse output as needed.
290
290
  - Scope denials (e.g. a file or folder outside the task scope) print to **stderr** and exit with a **non-zero** status, so a denial in an `&&` chain stops the chain instead of being treated as success. Read the stderr message: it names the in-scope folders/documents to redirect you, rather than retrying the same out-of-scope target.
291
+ - Skills in the knowledge base are off limits. A skill is a folder with a `SKILL.md` (or `skills.md`) file, including everything inside it. `search` and `ls` hide them, and `read`, `download`, `upload`, `rm`, `mv`, `mkdir`, `rmdir` and `mvdir` refuse them. Only your personal skills folders (`<home>/skills-conduct/space-<spaceId>` in your own home) are exempt. Never retry a denied command another way.
291
292
 
292
293
  ## Interactive Mode
293
294
 
@@ -10,6 +10,7 @@ from typing import Any
10
10
  import unique_sdk
11
11
  from unique_sdk.cli.config import Config
12
12
  from unique_sdk.cli.metadata_filter import MetadataFilter
13
+ from unique_sdk.cli.skill_guard import SkillGuard, folder_ids_from_metadata
13
14
  from unique_sdk.cli.workspace import manifest_path as workspace_manifest_path
14
15
 
15
16
  _SEARCH_CONFIG_FILENAME = ".unique-search.json"
@@ -180,6 +181,9 @@ class ShellState:
180
181
  self.mcp_tool_reference_mappings: dict[str, dict[str, Any]] = (
181
182
  _load_mcp_tool_configs()
182
183
  )
184
+ # Independent of scopeIds and metaDataFilter: a skill folder inside the
185
+ # KB scope is still off limits for writes. See UN-25784.
186
+ self.skill_guard = SkillGuard(config)
183
187
 
184
188
  @property
185
189
  def uploaded_search_available(self) -> bool:
@@ -420,6 +424,44 @@ class ShellState:
420
424
  self._content_info_cache[content_id] = info
421
425
  return info
422
426
 
427
+ def is_skill_content_write_denied(
428
+ self, content_id: str, *, new_name: str | None = None
429
+ ) -> bool:
430
+ """True when changing *content_id* would change a protected skill.
431
+
432
+ Content owned by a chat instead of a folder is never part of a skill.
433
+ Fails closed when the content can't be looked up.
434
+ """
435
+ info = self._get_content_info(content_id)
436
+ if not info:
437
+ return True
438
+ owner_id = info.get("ownerId") or ""
439
+ if not owner_id.startswith("scope_"):
440
+ return False
441
+ return self.skill_guard.is_folder_write_denied(owner_id, new_file_name=new_name)
442
+
443
+ def is_skill_content_read_denied(self, content_id: str) -> bool:
444
+ """True when *content_id* is part of a protected skill.
445
+
446
+ Content owned by a chat instead of a folder is never part of a skill.
447
+ Fails closed when the content can't be looked up.
448
+ """
449
+ info = self._get_content_info(content_id)
450
+ if not info:
451
+ return True
452
+ owner_id = info.get("ownerId") or ""
453
+ if not owner_id.startswith("scope_"):
454
+ return False
455
+ return self.skill_guard.is_folder_hidden(owner_id)
456
+
457
+ def is_skill_search_hit_hidden(self, hit: Any) -> bool:
458
+ """True when a search result sits in a protected skill."""
459
+ folder_ids = folder_ids_from_metadata(getattr(hit, "metadata", None))
460
+ if folder_ids:
461
+ return self.skill_guard.is_folder_path_hidden(folder_ids)
462
+ content_id = getattr(hit, "id", "") or ""
463
+ return bool(content_id) and self.is_skill_content_read_denied(content_id)
464
+
423
465
  def resolve_content_title(self, content_id: str) -> str:
424
466
  """Human-readable title for a content id, falling back to the id.
425
467