unique-sdk 2026.42.0.dev4__tar.gz → 2026.42.0.dev5__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.dev4 → unique_sdk-2026.42.0.dev5}/PKG-INFO +1 -1
  2. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/pyproject.toml +1 -2
  3. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_content.py +0 -1
  4. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/files.py +0 -11
  5. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/folders.py +2 -11
  6. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/navigation.py +2 -20
  7. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/read.py +0 -3
  8. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/search.py +0 -1
  9. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -1
  10. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/state.py +0 -42
  11. unique_sdk-2026.42.0.dev4/unique_sdk/cli/skill_guard.py +0 -314
  12. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/README.md +0 -0
  13. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/__init__.py +0 -0
  14. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_api_requestor.py +0 -0
  15. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_api_resource.py +0 -0
  16. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_api_version.py +0 -0
  17. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_error.py +0 -0
  18. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_http_client.py +0 -0
  19. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_list_object.py +0 -0
  20. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_object_classes.py +0 -0
  21. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_request_options.py +0 -0
  22. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_unique_object.py +0 -0
  23. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_unique_ql.py +0 -0
  24. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_unique_response.py +0 -0
  25. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_util.py +0 -0
  26. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_version.py +0 -0
  27. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/_webhook.py +0 -0
  28. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/__init__.py +0 -0
  29. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_acronyms.py +0 -0
  30. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_agentic_table.py +0 -0
  31. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_analytics_order.py +0 -0
  32. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_benchmarking.py +0 -0
  33. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_briefing.py +0 -0
  34. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_chat_completion.py +0 -0
  35. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_context_memory.py +0 -0
  36. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_elicitation.py +0 -0
  37. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_embedding.py +0 -0
  38. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_event.py +0 -0
  39. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_folder.py +0 -0
  40. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_group.py +0 -0
  41. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_integrated.py +0 -0
  42. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_llm_models.py +0 -0
  43. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_mcp.py +0 -0
  44. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_message.py +0 -0
  45. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_message_assessment.py +0 -0
  46. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_message_execution.py +0 -0
  47. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_message_log.py +0 -0
  48. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_message_tool.py +0 -0
  49. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_module.py +0 -0
  50. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  51. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_search.py +0 -0
  52. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_search_string.py +0 -0
  53. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  54. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_space.py +0 -0
  55. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_user.py +0 -0
  56. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/api_resources/_web_search.py +0 -0
  57. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/__init__.py +0 -0
  58. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/__main__.py +0 -0
  59. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/cli.py +0 -0
  60. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/__init__.py +0 -0
  61. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  62. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/agentic_table.py +0 -0
  63. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  64. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/browser.py +0 -0
  65. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/cite_file.py +0 -0
  66. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/elicitation.py +0 -0
  67. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/mcp.py +0 -0
  68. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  69. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/subagent.py +0 -0
  70. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/web_search.py +0 -0
  71. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/commands/web_search_config.py +0 -0
  72. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/config.py +0 -0
  73. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/formatting.py +0 -0
  74. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/identity.py +0 -0
  75. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/metadata_filter.py +0 -0
  76. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/shell.py +0 -0
  77. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  78. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  79. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  82. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  83. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  84. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  85. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.42.0.dev4 → unique_sdk-2026.42.0.dev5}/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.dev4
3
+ Version: 2026.42.0.dev5
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.dev4"
3
+ version = "2026.42.0.dev5"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -91,7 +91,6 @@ 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",
95
94
  "unit: unit tests",
96
95
  "verified: AI-generated tests with human verification",
97
96
  ]
@@ -21,7 +21,6 @@ class Content(APIResource["Content"]):
21
21
 
22
22
  id: str
23
23
  key: str
24
- ownerId: str
25
24
  url: str | None
26
25
  title: str | None
27
26
  updatedAt: str
@@ -11,7 +11,6 @@ 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
15
14
  from unique_sdk.cli.state import ShellState
16
15
  from unique_sdk.utils.file_io import download_content, upload_file
17
16
 
@@ -338,10 +337,6 @@ def cmd_upload(
338
337
  "upload: permission denied: destination is outside your "
339
338
  f"task scope ({state.scope_denial_hint()})."
340
339
  )
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)
345
340
 
346
341
  mime_type = _detect_upload_mime_type(path)
347
342
 
@@ -431,8 +426,6 @@ def cmd_download(
431
426
  """Download a file by name or content ID."""
432
427
  try:
433
428
  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)
436
429
 
437
430
  raw_name = (
438
431
  display_name if not display_name.startswith("cont_") else f"{content_id}"
@@ -469,8 +462,6 @@ def cmd_rm(state: ShellState, name_or_id: str) -> str:
469
462
  content_id, display_name = _resolve_content_id(
470
463
  state, name_or_id, allow_chat_files=False
471
464
  )
472
- if state.is_skill_content_write_denied(content_id):
473
- return skill_denial("rm", display_name)
474
465
  unique_sdk.Content.delete(
475
466
  user_id=state.config.user_id,
476
467
  company_id=state.config.company_id,
@@ -490,8 +481,6 @@ def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
490
481
  content_id, display_name = _resolve_content_id(
491
482
  state, old_name, allow_chat_files=False
492
483
  )
493
- if state.is_skill_content_write_denied(content_id, new_name=new_name):
494
- return skill_denial("mv", display_name)
495
484
  result = unique_sdk.Content.update(
496
485
  user_id=state.config.user_id,
497
486
  company_id=state.config.company_id,
@@ -6,7 +6,6 @@ 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
10
9
  from unique_sdk.cli.state import ShellState
11
10
 
12
11
 
@@ -60,8 +59,6 @@ def cmd_mkdir(state: ShellState, name: str) -> str:
60
59
  "mkdir: permission denied: destination is outside your task scope "
61
60
  f"({state.scope_denial_hint()})."
62
61
  )
63
- if state.skill_guard.is_path_write_denied(full_path):
64
- return skill_denial("mkdir", full_path)
65
62
  try:
66
63
  result = unique_sdk.Folder.create_paths(
67
64
  user_id=state.config.user_id,
@@ -87,14 +84,11 @@ def cmd_rmdir(state: ShellState, target: str, recursive: bool = False) -> str:
87
84
  and not state.is_folder_target_within_workspace(target)
88
85
  ):
89
86
  return "rmdir: permission denied (outside workspace scope)"
90
- scope_id = _resolve_folder_scope_id(state, target)
91
- if _metadata_filter_denies_folder(state, scope_id):
87
+ if _metadata_filter_denies_folder(state, _resolve_folder_scope_id(state, target)):
92
88
  return (
93
89
  "rmdir: permission denied: target is outside your task scope "
94
90
  f"({state.scope_denial_hint()})."
95
91
  )
96
- if state.skill_guard.is_folder_write_denied(scope_id, include_subtree=True):
97
- return skill_denial("rmdir", target)
98
92
  try:
99
93
  if target.startswith("scope_"):
100
94
  unique_sdk.Folder.delete(
@@ -128,14 +122,11 @@ def cmd_mvdir(state: ShellState, old_name: str, new_name: str) -> str:
128
122
  and not state.is_folder_target_within_workspace(old_name)
129
123
  ):
130
124
  return "mvdir: permission denied (outside workspace scope)"
131
- scope_id = _resolve_folder_scope_id(state, old_name)
132
- if _metadata_filter_denies_folder(state, scope_id):
125
+ if _metadata_filter_denies_folder(state, _resolve_folder_scope_id(state, old_name)):
133
126
  return (
134
127
  "mvdir: permission denied: target is outside your task scope "
135
128
  f"({state.scope_denial_hint()})."
136
129
  )
137
- if state.skill_guard.is_folder_write_denied(scope_id, include_subtree=True):
138
- return skill_denial("mvdir", old_name)
139
130
  try:
140
131
  if old_name.startswith("scope_"):
141
132
  result = unique_sdk.Folder.update(
@@ -7,7 +7,6 @@ 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
11
10
  from unique_sdk.cli.state import ShellState
12
11
 
13
12
 
@@ -44,8 +43,6 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
44
43
  f"ls: permission denied: target is outside your task scope "
45
44
  f"({state.scope_denial_hint()})."
46
45
  )
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)
49
46
 
50
47
  # At root with a per-message KB scope (e.g. an Agentic Table column's
51
48
  # scope_rules), show only the in-scope folders and explicitly-scoped
@@ -70,11 +67,6 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
70
67
  )
71
68
  except unique_sdk.UniqueError:
72
69
  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
- ]
78
70
  scoped_files: list[Any] = []
79
71
  for cid in content_ids:
80
72
  # A contentId mentioned in the filter is not necessarily in
@@ -85,8 +77,6 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
85
77
  # UN-21780.
86
78
  if not state.is_content_within_workspace(cid):
87
79
  continue
88
- if state.is_skill_content_read_denied(cid):
89
- continue
90
80
  try:
91
81
  info = unique_sdk.Content.get_info(
92
82
  user_id=state.config.user_id,
@@ -119,11 +109,6 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
119
109
  folders.append(info)
120
110
  except unique_sdk.UniqueError:
121
111
  pass
122
- folders = [
123
- folder
124
- for folder in folders
125
- if not state.skill_guard.is_folder_hidden(folder.get("id", ""))
126
- ]
127
112
  output = format_ls(folders, [])
128
113
  summary = f"\n{len(folders)} folder(s), 0 file(s)"
129
114
  return output + summary
@@ -139,8 +124,7 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
139
124
  company_id=state.config.company_id,
140
125
  **folder_params,
141
126
  )
142
- listed_folders = folder_result.get("folderInfos", [])
143
- folders = state.skill_guard.visible_child_folders(listed_folders)
127
+ folders = folder_result.get("folderInfos", [])
144
128
 
145
129
  content_result = unique_sdk.Content.get_infos(
146
130
  user_id=state.config.user_id,
@@ -149,9 +133,7 @@ def cmd_ls(state: ShellState, target: str | None = None) -> str:
149
133
  )
150
134
  files = content_result.get("contentInfos", [])
151
135
 
152
- total_folders = folder_result.get("totalCount", len(listed_folders)) - (
153
- len(listed_folders) - len(folders)
154
- )
136
+ total_folders = folder_result.get("totalCount", len(folders))
155
137
  total_files = content_result.get("totalCount", len(files))
156
138
 
157
139
  # With a per-message filter, listing inside an allowed folder must not
@@ -18,7 +18,6 @@ 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
22
21
  from unique_sdk.cli.state import ShellState
23
22
 
24
23
  READ_ERROR_PREFIX = "read:"
@@ -104,8 +103,6 @@ def cmd_read(
104
103
  f"task scope ({state.scope_denial_hint()}). Use 'unique-cli search' "
105
104
  "or 'ls' within that scope instead."
106
105
  )
107
- if state.is_skill_content_read_denied(cont_id):
108
- return skill_denial("read", cont_id)
109
106
 
110
107
  try:
111
108
  results = unique_sdk.Content.search(
@@ -280,7 +280,6 @@ 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)]
284
283
 
285
284
  except (ValueError, unique_sdk.UniqueError) as e:
286
285
  return f"{SEARCH_ERROR_PREFIX} {e}"
@@ -288,7 +288,6 @@ 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.
292
291
 
293
292
  ## Interactive Mode
294
293
 
@@ -10,7 +10,6 @@ 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
14
13
  from unique_sdk.cli.workspace import manifest_path as workspace_manifest_path
15
14
 
16
15
  _SEARCH_CONFIG_FILENAME = ".unique-search.json"
@@ -181,9 +180,6 @@ class ShellState:
181
180
  self.mcp_tool_reference_mappings: dict[str, dict[str, Any]] = (
182
181
  _load_mcp_tool_configs()
183
182
  )
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)
187
183
 
188
184
  @property
189
185
  def uploaded_search_available(self) -> bool:
@@ -424,44 +420,6 @@ class ShellState:
424
420
  self._content_info_cache[content_id] = info
425
421
  return info
426
422
 
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
-
465
423
  def resolve_content_title(self, content_id: str) -> str:
466
424
  """Human-readable title for a content id, falling back to the id.
467
425
 
@@ -1,314 +0,0 @@
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_")]