unique-sdk 2026.46.0.dev2__tar.gz → 2026.46.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.46.0.dev2 → unique_sdk-2026.46.0.dev4}/PKG-INFO +1 -1
  2. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/pyproject.toml +1 -1
  3. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_space.py +4 -0
  4. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/navigation.py +10 -1
  5. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/search.py +3 -5
  6. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/metadata_filter.py +79 -18
  7. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/state.py +19 -5
  8. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/README.md +0 -0
  9. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/__init__.py +0 -0
  10. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_api_requestor.py +0 -0
  11. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_api_resource.py +0 -0
  12. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_api_version.py +0 -0
  13. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_error.py +0 -0
  14. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_http_client.py +0 -0
  15. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_list_object.py +0 -0
  16. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_object_classes.py +0 -0
  17. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_request_options.py +0 -0
  18. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_unique_object.py +0 -0
  19. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_unique_ql.py +0 -0
  20. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_unique_response.py +0 -0
  21. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_util.py +0 -0
  22. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_version.py +0 -0
  23. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/_webhook.py +0 -0
  24. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/__init__.py +0 -0
  25. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_acronyms.py +0 -0
  26. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_agentic_table.py +0 -0
  27. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_analytics_order.py +0 -0
  28. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_benchmarking.py +0 -0
  29. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_briefing.py +0 -0
  30. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_chat_completion.py +0 -0
  31. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_content.py +0 -0
  32. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_context_memory.py +0 -0
  33. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_elicitation.py +0 -0
  34. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_embedding.py +0 -0
  35. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_event.py +0 -0
  36. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_folder.py +0 -0
  37. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_group.py +0 -0
  38. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_integrated.py +0 -0
  39. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_llm_models.py +0 -0
  40. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_mcp.py +0 -0
  41. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_message.py +0 -0
  42. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_message_assessment.py +0 -0
  43. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_message_execution.py +0 -0
  44. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_message_log.py +0 -0
  45. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_message_tool.py +0 -0
  46. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_module.py +0 -0
  47. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  48. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_search.py +0 -0
  49. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_search_string.py +0 -0
  50. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  51. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_user.py +0 -0
  52. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/api_resources/_web_search.py +0 -0
  53. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/__init__.py +0 -0
  54. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/__main__.py +0 -0
  55. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/cli.py +0 -0
  56. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/__init__.py +0 -0
  57. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  58. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/agentic_table.py +0 -0
  59. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  60. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/browser.py +0 -0
  61. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/cite_file.py +0 -0
  62. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/elicitation.py +0 -0
  63. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/files.py +0 -0
  64. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/folders.py +0 -0
  65. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/mcp.py +0 -0
  66. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/read.py +0 -0
  67. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  68. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/subagent.py +0 -0
  69. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/web_search.py +0 -0
  70. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/commands/web_search_config.py +0 -0
  71. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/config.py +0 -0
  72. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/formatting.py +0 -0
  73. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/identity.py +0 -0
  74. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/paths.py +0 -0
  75. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/shell.py +0 -0
  76. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  77. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  78. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  79. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  80. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  81. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  82. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  83. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  84. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  85. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/cli/workspace.py +0 -0
  86. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/utils/analytics_order_run.py +0 -0
  87. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/utils/benchmarking_run.py +0 -0
  88. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/utils/chat_history.py +0 -0
  89. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/utils/chat_in_space.py +0 -0
  90. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/utils/file_io.py +0 -0
  91. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.0.dev4}/unique_sdk/utils/sources.py +0 -0
  92. {unique_sdk-2026.46.0.dev2 → unique_sdk-2026.46.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.46.0.dev2
3
+ Version: 2026.46.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.46.0.dev2"
3
+ version = "2026.46.0.dev4"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -271,9 +271,13 @@ class Space(APIResource["Space"]):
271
271
 
272
272
  Attributes:
273
273
  title: New chat title. Must be a non-empty string.
274
+ isGenerated: Marks the title as generated by an assistant. It is
275
+ applied only while the chat still has its prompt-based title;
276
+ otherwise the request fails with HTTP 409.
274
277
  """
275
278
 
276
279
  title: str
280
+ isGenerated: NotRequired[bool]
277
281
 
278
282
  class ChatResult(TypedDict):
279
283
  id: str
@@ -338,16 +338,23 @@ def cmd_ls(
338
338
  # Folder-only filters keep every file (each passes the folderIdPath
339
339
  # leaf); the verdict is cached per content id. See UN-21780.
340
340
  # The paging note counts rows read from the server, before the scope filter.
341
+ scanned_folders = len(folders)
341
342
  scanned_files = len(files)
342
343
  if state.workspace_metadata_filter is not None:
344
+ folders = [
345
+ folder
346
+ for folder in folders
347
+ if state.folder_allowed_by_metadata_filter(str(folder.get("id", "")))
348
+ ]
343
349
  files = [
344
350
  f for f in files if state.is_content_within_workspace(f.get("id", ""))
345
351
  ]
352
+ hidden_folders = scanned_folders - len(folders)
346
353
  note = _window_note(
347
354
  target if target is not None else resolved_path,
348
355
  skip,
349
356
  limit,
350
- {"folders": len(folders), "files": scanned_files},
357
+ {"folders": scanned_folders, "files": scanned_files},
351
358
  {"folders": total_folders, "files": total_files},
352
359
  hidden_files=scanned_files - len(files),
353
360
  )
@@ -363,6 +370,8 @@ def cmd_ls(
363
370
  f"\n{total_folders} folder(s), {len(files)} file(s) in task scope "
364
371
  f"(of {total_files} in folder)"
365
372
  )
373
+ if hidden_folders:
374
+ summary += f" ({hidden_folders} folder(s) hidden by task scope)"
366
375
  return output + summary + note
367
376
 
368
377
  summary = f"\n{total_folders} folder(s), {total_files} file(s)"
@@ -216,11 +216,9 @@ def cmd_search(
216
216
  folder_scope_id = _resolve_folder_to_scope_id(state, folder)
217
217
  elif state.scope_id and not state.workspace_metadata_filter:
218
218
  # The incidental cwd narrows search only when there is no
219
- # per-message filter. `cd` is not gated by the filter, so an agent
220
- # can sit in an out-of-scope folder; ANDing that cwd with the task
221
- # filter would silently return no hits and look like an empty KB
222
- # rather than a scope mismatch. With a filter active, only an
223
- # explicit --folder narrows further. See UN-21780.
219
+ # per-message filter. With a filter active, only an explicit
220
+ # --folder narrows further: ANDing the cwd would turn a scope
221
+ # mismatch into an empty result. See UN-21780.
224
222
  folder_scope_id = state.scope_id
225
223
 
226
224
  scope_ids: list[str] | None = None
@@ -127,18 +127,54 @@ def _collect_navigable_folder_ids(
127
127
  return folder_ids
128
128
  if _is_negated_operator(node.get("operator")):
129
129
  return folder_ids
130
+ for scope_id in _folder_scope_ids(node):
131
+ if scope_id not in folder_ids:
132
+ folder_ids.append(scope_id)
133
+ return folder_ids
134
+
135
+
136
+ def _folder_scope_ids(node: dict[Any, Any]) -> list[str]:
137
+ """Folder scope ids named by a ``folderIdPath`` leaf, else an empty list."""
130
138
  path = node.get("path")
131
139
  field = path[0] if isinstance(path, list) and path else path
132
- if field == "folderIdPath":
133
- value = node.get("value")
134
- values = value if isinstance(value, list) else [value]
135
- for v in values:
136
- scope_id = _extract_target_scope_id(v)
137
- if scope_id and scope_id not in folder_ids:
138
- folder_ids.append(scope_id)
140
+ if field != "folderIdPath":
141
+ return []
142
+ value = node.get("value")
143
+ values = value if isinstance(value, list) else [value]
144
+ scope_ids: list[str] = []
145
+ for item in values:
146
+ scope_id = _extract_target_scope_id(item)
147
+ if scope_id and scope_id not in scope_ids:
148
+ scope_ids.append(scope_id)
149
+ return scope_ids
150
+
151
+
152
+ def _collect_excluded_folder_ids(
153
+ node: Any, folder_ids: list[str] | None = None
154
+ ) -> list[str]:
155
+ """Every negated ``folderIdPath`` leaf in the tree."""
156
+ if folder_ids is None:
157
+ folder_ids = []
158
+ if not isinstance(node, dict):
159
+ return folder_ids
160
+ for key in ("and", "or"):
161
+ if key in node:
162
+ for child in node.get(key) or []:
163
+ _collect_excluded_folder_ids(child, folder_ids)
164
+ return folder_ids
165
+ if not _is_negated_operator(node.get("operator")):
166
+ return folder_ids
167
+ for scope_id in _folder_scope_ids(node):
168
+ if scope_id not in folder_ids:
169
+ folder_ids.append(scope_id)
139
170
  return folder_ids
140
171
 
141
172
 
173
+ def _path_is_inside_any(target: str, roots: list[str]) -> bool:
174
+ """True when *target* is one of *roots* or a descendant of one."""
175
+ return any(target == root or target.startswith(root + "/") for root in roots)
176
+
177
+
142
178
  def _collect_filter_targets(
143
179
  node: Any,
144
180
  folder_ids: list[str] | None = None,
@@ -357,9 +393,15 @@ class MetadataFilter:
357
393
  """Folder scope ids the filter grants for navigation (ls/cd into).
358
394
 
359
395
  Excludes folders only reachable as an ``or`` alternative to a
360
- ``contentId`` allowlist, which are not standalone-browsable.
396
+ ``contentId`` allowlist, which are not standalone-browsable. Also
397
+ drops a grant named by any exclusion in the tree.
361
398
  """
362
- return _collect_navigable_folder_ids(self._tree)
399
+ excluded = set(_collect_excluded_folder_ids(self._tree))
400
+ return [
401
+ scope_id
402
+ for scope_id in _collect_navigable_folder_ids(self._tree)
403
+ if scope_id not in excluded
404
+ ]
363
405
 
364
406
  def _navigable_folder_paths(self) -> list[str]:
365
407
  return [
@@ -370,21 +412,35 @@ class MetadataFilter:
370
412
  if p
371
413
  ]
372
414
 
373
- def allows_folder_scope(self, scope_id: str) -> bool:
374
- """True if *scope_id* is inside one of the filter's navigable scopes.
415
+ def _excluded_scope_ids(self) -> list[str]:
416
+ return _collect_excluded_folder_ids(self._tree)
375
417
 
376
- Gates explicit folder targets (e.g. ``ls <path>``) so navigation cannot
377
- enumerate folders outside the per-message scope.
378
- """
418
+ def _resolved_exclusion_paths(self) -> list[str] | None:
419
+ """Exclusion paths, or ``None`` when any exclusion failed to resolve."""
420
+ paths: list[str] = []
421
+ for scope_id in self._excluded_scope_ids():
422
+ path = self._resolve_scope_path(scope_id)
423
+ if not path:
424
+ return None
425
+ paths.append(path)
426
+ return paths
427
+
428
+ def allows_folder_scope(self, scope_id: str) -> bool:
429
+ """True if *scope_id* is in a navigable scope and not under an exclusion."""
430
+ if scope_id in self._excluded_scope_ids():
431
+ return False
432
+ excluded_paths = self._resolved_exclusion_paths()
433
+ if excluded_paths is None:
434
+ return False
379
435
  folder_paths = self._navigable_folder_paths()
380
436
  if not folder_paths:
381
437
  return False
382
438
  target_path = self._resolve_scope_path(scope_id)
383
439
  if not target_path:
384
440
  return False
385
- return any(
386
- target_path == p or target_path.startswith(p + "/") for p in folder_paths
387
- )
441
+ if _path_is_inside_any(target_path, excluded_paths):
442
+ return False
443
+ return _path_is_inside_any(target_path, folder_paths)
388
444
 
389
445
  def allows_folder_path(self, folder_path: str) -> bool:
390
446
  """True if an absolute folder *path* lies within a navigable scope.
@@ -393,11 +449,16 @@ class MetadataFilter:
393
449
  scope id yet (e.g. a ``mkdir`` destination), so a ``..`` traversal can't
394
450
  create structure outside the task scope.
395
451
  """
452
+ excluded_paths = self._resolved_exclusion_paths()
453
+ if excluded_paths is None:
454
+ return False
396
455
  paths = self._navigable_folder_paths()
397
456
  if not paths:
398
457
  return False
399
458
  norm = os.path.normpath(folder_path)
400
- return any(norm == p or norm.startswith(p + "/") for p in paths)
459
+ if _path_is_inside_any(norm, excluded_paths):
460
+ return False
461
+ return _path_is_inside_any(norm, paths)
401
462
 
402
463
  def scope(self) -> tuple[list[str], list[str]]:
403
464
  """Return ``(folder_paths, content_ids)`` referenced by the filter.
@@ -12,6 +12,7 @@ from unique_sdk.cli.config import Config
12
12
  from unique_sdk.cli.metadata_filter import MetadataFilter
13
13
  from unique_sdk.cli.paths import canonical_folder_path, same_folder_path
14
14
  from unique_sdk.cli.workspace import manifest_path as workspace_manifest_path
15
+ from unique_sdk.cli.workspace import workspace_root
15
16
 
16
17
  _SEARCH_CONFIG_FILENAME = ".unique-search.json"
17
18
  _UPLOADED_CONFIG_FILENAME = ".unique-uploaded.json"
@@ -20,8 +21,8 @@ _CHAT_FILES_MANIFEST_PATH = Path(".unique") / "chat-files.json"
20
21
 
21
22
 
22
23
  def _load_search_config() -> dict[str, Any]:
23
- """Load ``.unique-search.json`` from the cwd, or ``{}`` when absent/invalid."""
24
- config_path = Path.cwd() / _SEARCH_CONFIG_FILENAME
24
+ """Load ``.unique-search.json`` from the workspace root, not the shell cwd."""
25
+ config_path = workspace_root() / _SEARCH_CONFIG_FILENAME
25
26
  if not config_path.is_file():
26
27
  return {}
27
28
  try:
@@ -32,7 +33,7 @@ def _load_search_config() -> dict[str, Any]:
32
33
 
33
34
 
34
35
  def _load_uploaded_config() -> dict[str, Any]:
35
- """Load ``.unique-uploaded.json`` from the cwd, or ``{}`` when absent/invalid.
36
+ """Load ``.unique-uploaded.json`` from the workspace root, or ``{}`` when absent or invalid.
36
37
 
37
38
  Written by the Swappable Intelligence runner when the task carries per-row
38
39
  uploaded documents (an Agentic Table row's ``selectedUploadedFiles``). It
@@ -41,7 +42,7 @@ def _load_uploaded_config() -> dict[str, Any]:
41
42
  the knowledge-base folder scopes, so the scope-bound ``unique-cli search``
42
43
  structurally cannot return them. See UN-21780.
43
44
  """
44
- config_path = Path.cwd() / _UPLOADED_CONFIG_FILENAME
45
+ config_path = workspace_root() / _UPLOADED_CONFIG_FILENAME
45
46
  if not config_path.is_file():
46
47
  return {}
47
48
  try:
@@ -61,7 +62,7 @@ def _load_mcp_tool_configs() -> dict[str, dict[str, Any]]:
61
62
  Keyed by the same namespaced tool name the agent passes to ``unique-cli
62
63
  mcp``. Returns ``{}`` when absent or invalid.
63
64
  """
64
- config_path = Path.cwd() / _MCP_TOOLS_CONFIG_FILENAME
65
+ config_path = workspace_root() / _MCP_TOOLS_CONFIG_FILENAME
65
66
  if not config_path.is_file():
66
67
  return {}
67
68
  try:
@@ -562,6 +563,17 @@ class ShellState:
562
563
  def prompt(self) -> str:
563
564
  return f"{self._path}> "
564
565
 
566
+ def _require_navigable_folder(self, scope_id: str) -> None:
567
+ """Refuse a folder the per-message filter excludes. See UN-28458."""
568
+ if self._metadata_filter is None:
569
+ return
570
+ if self.folder_allowed_by_metadata_filter(scope_id):
571
+ return
572
+ raise ValueError(
573
+ "permission denied: target is outside your task scope "
574
+ f"({self.scope_denial_hint()})."
575
+ )
576
+
565
577
  def _enter_folder(self, scope_id: str, folder_path: str) -> str:
566
578
  """Store *folder_path* only when *scope_id* is that exact folder."""
567
579
  actual = canonical_folder_path(
@@ -571,6 +583,7 @@ class ShellState:
571
583
  )
572
584
  if not same_folder_path(actual, folder_path):
573
585
  raise ValueError(f"cannot change to {folder_path}: resolved to {actual}")
586
+ self._require_navigable_folder(scope_id)
574
587
  self._scope_id = scope_id
575
588
  self._path = folder_path
576
589
  return self._path
@@ -618,6 +631,7 @@ class ShellState:
618
631
  company_id=self.config.company_id,
619
632
  scope_id=target,
620
633
  )
634
+ self._require_navigable_folder(info["id"])
621
635
  self._scope_id = info["id"]
622
636
  self._path = path_resp["folderPath"]
623
637
  return self._path