unique-sdk 2026.28.0.dev6__tar.gz → 2026.28.0.dev8__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 (90) hide show
  1. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/PKG-INFO +1 -1
  2. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/pyproject.toml +1 -1
  3. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/cli.py +60 -19
  4. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/cite_file.py +41 -2
  5. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/files.py +154 -21
  6. unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/folders.py +149 -0
  7. unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/mcp.py +342 -0
  8. unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/navigation.py +165 -0
  9. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/read.py +5 -1
  10. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/search.py +31 -1
  11. unique_sdk-2026.28.0.dev8/unique_sdk/cli/metadata_filter.py +434 -0
  12. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +20 -4
  13. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +16 -0
  14. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +14 -4
  15. unique_sdk-2026.28.0.dev8/unique_sdk/cli/state.py +548 -0
  16. unique_sdk-2026.28.0.dev6/unique_sdk/cli/commands/folders.py +0 -80
  17. unique_sdk-2026.28.0.dev6/unique_sdk/cli/commands/mcp.py +0 -93
  18. unique_sdk-2026.28.0.dev6/unique_sdk/cli/commands/navigation.py +0 -79
  19. unique_sdk-2026.28.0.dev6/unique_sdk/cli/state.py +0 -252
  20. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/README.md +0 -0
  21. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/__init__.py +0 -0
  22. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_requestor.py +0 -0
  23. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_resource.py +0 -0
  24. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_version.py +0 -0
  25. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_error.py +0 -0
  26. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_http_client.py +0 -0
  27. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_list_object.py +0 -0
  28. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_object_classes.py +0 -0
  29. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_request_options.py +0 -0
  30. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_object.py +0 -0
  31. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_ql.py +0 -0
  32. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_response.py +0 -0
  33. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_util.py +0 -0
  34. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_version.py +0 -0
  35. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/_webhook.py +0 -0
  36. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/__init__.py +0 -0
  37. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_acronyms.py +0 -0
  38. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_agentic_table.py +0 -0
  39. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_analytics_order.py +0 -0
  40. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_benchmarking.py +0 -0
  41. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_briefing.py +0 -0
  42. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_chat_completion.py +0 -0
  43. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_content.py +0 -0
  44. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  45. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_elicitation.py +0 -0
  46. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_embedding.py +0 -0
  47. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_event.py +0 -0
  48. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_folder.py +0 -0
  49. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_group.py +0 -0
  50. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_integrated.py +0 -0
  51. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_llm_models.py +0 -0
  52. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_mcp.py +0 -0
  53. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message.py +0 -0
  54. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_assessment.py +0 -0
  55. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_execution.py +0 -0
  56. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_log.py +0 -0
  57. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_tool.py +0 -0
  58. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_module.py +0 -0
  59. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  60. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search.py +0 -0
  61. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search_string.py +0 -0
  62. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  63. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_space.py +0 -0
  64. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_user.py +0 -0
  65. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_web_search.py +0 -0
  66. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__init__.py +0 -0
  67. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__main__.py +0 -0
  68. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/__init__.py +0 -0
  69. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  70. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  71. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/elicitation.py +0 -0
  72. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  73. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/subagent.py +0 -0
  74. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search.py +0 -0
  75. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search_config.py +0 -0
  76. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/config.py +0 -0
  77. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/formatting.py +0 -0
  78. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/shell.py +0 -0
  79. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
  80. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  81. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  82. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  83. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  84. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/analytics_order_run.py +0 -0
  85. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/benchmarking_run.py +0 -0
  86. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_history.py +0 -0
  87. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_in_space.py +0 -0
  88. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/file_io.py +0 -0
  89. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/sources.py +0 -0
  90. {unique_sdk-2026.28.0.dev6 → unique_sdk-2026.28.0.dev8}/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.28.0.dev6
3
+ Version: 2026.28.0.dev8
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.28.0.dev6"
3
+ version = "2026.28.0.dev8"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -2,12 +2,16 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from typing import Any
6
7
 
7
8
  import click
8
9
 
9
10
  from unique_sdk.cli import __version__
10
11
  from unique_sdk.cli.commands.cite_file import cmd_cite_file
12
+ from unique_sdk.cli.commands.cite_file import (
13
+ is_error_output as _is_cite_error_output,
14
+ )
11
15
  from unique_sdk.cli.commands.dynamic_frontend import (
12
16
  cmd_dynamic_frontend_delete,
13
17
  cmd_dynamic_frontend_deploy,
@@ -30,6 +34,9 @@ from unique_sdk.cli.commands.files import (
30
34
  cmd_upload,
31
35
  cmd_versions,
32
36
  )
37
+ from unique_sdk.cli.commands.files import (
38
+ is_permission_denied_output as _is_permission_denied_output,
39
+ )
33
40
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
34
41
  from unique_sdk.cli.commands.mcp import cmd_mcp
35
42
  from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
@@ -132,6 +139,25 @@ class LazyState:
132
139
  return ctx.obj
133
140
 
134
141
 
142
+ def emit(
143
+ output: str,
144
+ *,
145
+ is_error: Callable[[str], bool] = _is_permission_denied_output,
146
+ ) -> None:
147
+ """Print a command's *output* and exit non-zero when it is an error.
148
+
149
+ Errors (e.g. a scope denial) are written to stderr and raise
150
+ ``SystemExit(1)`` so agent ``&&`` chains stop cleanly; successful output
151
+ goes to stdout. The default predicate matches the ``<cmd>: permission
152
+ denied`` shape shared by the scope-gated commands; pass ``is_error`` for
153
+ commands with their own error-detection helper (cite/read/search/...).
154
+ """
155
+ if is_error(output):
156
+ click.echo(output, err=True)
157
+ raise SystemExit(1)
158
+ click.echo(output)
159
+
160
+
135
161
  @click.group(invoke_without_command=True, help=MAIN_HELP)
136
162
  @click.version_option(version=__version__, prog_name="unique-cli")
137
163
  @click.pass_context
@@ -203,7 +229,8 @@ def ls(ctx: click.Context, target: str | None) -> None:
203
229
  unique-cli ls /Reports List a specific path
204
230
  unique-cli ls scope_abc List by scope ID
205
231
  """
206
- click.echo(cmd_ls(LazyState.get(ctx), target))
232
+ output = cmd_ls(LazyState.get(ctx), target)
233
+ emit(output)
207
234
 
208
235
 
209
236
  @main.command()
@@ -221,7 +248,8 @@ def mkdir(ctx: click.Context, name: str) -> None:
221
248
  unique-cli mkdir Q2
222
249
  unique-cli mkdir "2025/Q1"
223
250
  """
224
- click.echo(cmd_mkdir(LazyState.get(ctx), name))
251
+ output = cmd_mkdir(LazyState.get(ctx), name)
252
+ emit(output)
225
253
 
226
254
 
227
255
  @main.command()
@@ -246,7 +274,8 @@ def rmdir(ctx: click.Context, target: str, recursive: bool) -> None:
246
274
  unique-cli rmdir /Reports/Q1 --recursive
247
275
  unique-cli rmdir scope_abc123 -r
248
276
  """
249
- click.echo(cmd_rmdir(LazyState.get(ctx), target, recursive=recursive))
277
+ output = cmd_rmdir(LazyState.get(ctx), target, recursive=recursive)
278
+ emit(output)
250
279
 
251
280
 
252
281
  @main.command()
@@ -265,7 +294,8 @@ def mvdir(ctx: click.Context, old_name: str, new_name: str) -> None:
265
294
  unique-cli mvdir Q1 "Q1-2025"
266
295
  unique-cli mvdir scope_abc123 "New Name"
267
296
  """
268
- click.echo(cmd_mvdir(LazyState.get(ctx), old_name, new_name))
297
+ output = cmd_mvdir(LazyState.get(ctx), old_name, new_name)
298
+ emit(output)
269
299
 
270
300
 
271
301
  @main.command()
@@ -298,7 +328,8 @@ def upload(ctx: click.Context, local_path: str, destination: str | None) -> None
298
328
  unique-cli upload ./report.pdf Q1/
299
329
  unique-cli upload ./report.pdf /Archive/2025/
300
330
  """
301
- click.echo(cmd_upload(LazyState.get(ctx), local_path, destination))
331
+ output = cmd_upload(LazyState.get(ctx), local_path, destination)
332
+ emit(output)
302
333
 
303
334
 
304
335
  @main.command()
@@ -324,7 +355,8 @@ def versions(
324
355
  unique-cli versions /Reports/Q1/report.pdf
325
356
  unique-cli versions cont_abc123 --take 10
326
357
  """
327
- click.echo(cmd_versions(LazyState.get(ctx), name_or_id, skip=skip, take=take))
358
+ output = cmd_versions(LazyState.get(ctx), name_or_id, skip=skip, take=take)
359
+ emit(output)
328
360
 
329
361
 
330
362
  @main.command(name="restore-version")
@@ -340,7 +372,8 @@ def restore_version(ctx: click.Context, content_version_id: str) -> None:
340
372
  Examples:
341
373
  unique-cli restore-version cver_abc123
342
374
  """
343
- click.echo(cmd_restore_version(LazyState.get(ctx), content_version_id))
375
+ output = cmd_restore_version(LazyState.get(ctx), content_version_id)
376
+ emit(output)
344
377
 
345
378
 
346
379
  @main.command()
@@ -365,7 +398,8 @@ def download(ctx: click.Context, name_or_id: str, local_dest: str | None) -> Non
365
398
  unique-cli download annual.pdf ./downloads/
366
399
  unique-cli download cont_abc123 ~/Desktop/
367
400
  """
368
- click.echo(cmd_download(LazyState.get(ctx), name_or_id, local_dest))
401
+ output = cmd_download(LazyState.get(ctx), name_or_id, local_dest)
402
+ emit(output)
369
403
 
370
404
 
371
405
  @main.command(name="cite")
@@ -414,7 +448,8 @@ def cite(
414
448
  unique-cli cite cont_abc123 --pages 1-4 --read-method indexed
415
449
  unique-cli cite notes.docx --read-method text
416
450
  """
417
- click.echo(cmd_cite_file(LazyState.get(ctx), name_or_id, pages, read_method))
451
+ output = cmd_cite_file(LazyState.get(ctx), name_or_id, pages, read_method)
452
+ emit(output, is_error=_is_cite_error_output)
418
453
 
419
454
 
420
455
  @main.command(name="read")
@@ -492,10 +527,7 @@ def read_cmd(
492
527
  to_page=to_page,
493
528
  max_chars=max_chars,
494
529
  )
495
- if _is_read_error_output(output):
496
- click.echo(output, err=True)
497
- raise SystemExit(1)
498
- click.echo(output)
530
+ emit(output, is_error=_is_read_error_output)
499
531
 
500
532
 
501
533
  @main.group(name="dynamic-frontend")
@@ -609,7 +641,8 @@ def rm(ctx: click.Context, name_or_id: str) -> None:
609
641
  unique-cli rm /Reports/Q1/report.pdf
610
642
  unique-cli rm cont_abc123
611
643
  """
612
- click.echo(cmd_rm(LazyState.get(ctx), name_or_id))
644
+ output = cmd_rm(LazyState.get(ctx), name_or_id)
645
+ emit(output)
613
646
 
614
647
 
615
648
  @main.command()
@@ -629,7 +662,8 @@ def mv(ctx: click.Context, old_name: str, new_name: str) -> None:
629
662
  unique-cli mv /Reports/Q1/annual.pdf annual-2025.pdf
630
663
  unique-cli mv cont_abc123 "New Title.pdf"
631
664
  """
632
- click.echo(cmd_mv_file(LazyState.get(ctx), old_name, new_name))
665
+ output = cmd_mv_file(LazyState.get(ctx), old_name, new_name)
666
+ emit(output)
633
667
 
634
668
 
635
669
  @main.command()
@@ -638,7 +672,11 @@ def mv(ctx: click.Context, old_name: str, new_name: str) -> None:
638
672
  "--folder",
639
673
  "-f",
640
674
  default=None,
641
- help="Restrict search to a folder (path, name, or scope ID). Defaults to current directory.",
675
+ help=(
676
+ "Restrict search to a folder (path, name, or scope ID). Without an "
677
+ "active task scope, defaults to the current directory; with one, the "
678
+ "task scope is the boundary and --folder ANDs an extra constraint."
679
+ ),
642
680
  )
643
681
  @click.option(
644
682
  "--metadata",
@@ -668,9 +706,12 @@ def search(
668
706
  both semantic similarity and keyword matching.
669
707
 
670
708
  \b
671
- By default, searches within the current directory scope with up
672
- to 200 results. Use --folder to target a different folder, and
673
- --metadata to filter by custom metadata fields.
709
+ Without an active task scope, searches within the current directory
710
+ scope with up to 200 results; use --folder to target a different
711
+ folder. When the task defines a per-message scope, that scope is the
712
+ search boundary regardless of the current directory, and --folder
713
+ ANDs an additional constraint. Use --metadata to filter by custom
714
+ metadata fields.
674
715
 
675
716
  \b
676
717
  Examples:
@@ -131,12 +131,27 @@ def _resolve_content_id_with_manifest(
131
131
  """Resolve a filename/content_id checking the chat-files manifest first.
132
132
 
133
133
  Resolution order:
134
- 1. If name_or_id starts with "cont_", return it directly.
134
+ 1. If name_or_id starts with "cont_", resolve its title via the API so the
135
+ citation renders with the document filename, not the opaque id.
135
136
  2. Check .unique/chat-files.json for a matching filename (exact or basename).
136
137
  3. Fall back to KB resolution via _resolve_content_id.
137
138
  """
138
139
  if name_or_id.startswith("cont_"):
139
- return name_or_id, name_or_id
140
+ # Gate the id *before* resolving its title, exactly like read's cont_
141
+ # fast-path (_resolve_content_id): resolve_content_title hits
142
+ # Content.get_info, so checking scope afterwards would probe the KB
143
+ # ahead of denial (a cross-scope existence/title oracle) and — without
144
+ # a per-message filter — skip the static scopeIds boundary that read
145
+ # enforces. is_content_within_workspace covers both the per-message
146
+ # filter and the static scope, and keeps the read-only chat-attachment
147
+ # exemption. See UN-21780.
148
+ if not state.is_content_within_workspace(name_or_id):
149
+ raise ValueError(
150
+ f"permission denied: {name_or_id} is outside your task scope "
151
+ f"({state.scope_denial_hint()}). Only cite documents within that "
152
+ "scope or files attached to this chat."
153
+ )
154
+ return name_or_id, state.resolve_content_title(name_or_id)
140
155
 
141
156
  manifest_path = Path.cwd() / _CHAT_FILES_MANIFEST
142
157
  if manifest_path.is_file():
@@ -181,6 +196,20 @@ def cmd_cite_file(
181
196
  except Exception as exc:
182
197
  return f"{CITE_ERROR_PREFIX} {exc}"
183
198
 
199
+ # When a per-message KB scope filter is active (e.g. an Agentic Table
200
+ # column's scope_rules), don't let the agent cite documents outside it.
201
+ # Chat-attached files are exempt — is_content_within_workspace allows them.
202
+ # Static-scope (no per-message filter) cite behaviour is left unchanged.
203
+ if (
204
+ state.workspace_metadata_filter is not None
205
+ and not state.is_content_within_workspace(content_id)
206
+ ):
207
+ return (
208
+ f"{CITE_ERROR_PREFIX} permission denied: {content_id} is outside your "
209
+ f"task scope ({state.scope_denial_hint()}). Only cite documents within "
210
+ "that scope or files attached to this chat."
211
+ )
212
+
184
213
  if pages and pages.strip() and _is_non_paginated(filename):
185
214
  suffix = Path(filename).suffix.lower()
186
215
  return (
@@ -253,3 +282,13 @@ def cmd_cite_file(
253
282
  return f"{CITE_ERROR_PREFIX} {exc}"
254
283
 
255
284
  return "\n".join(output_lines)
285
+
286
+
287
+ def is_error_output(output: str) -> bool:
288
+ """Return ``True`` when *output* is an error message from ``cmd_cite_file``.
289
+
290
+ Lets the one-shot dispatcher exit non-zero (so shell ``&&`` chains stop) on
291
+ any cite failure — invalid pages, missing file, or an out-of-scope denial.
292
+ Mirrors ``read.is_error_output``.
293
+ """
294
+ return output.startswith(CITE_ERROR_PREFIX)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import mimetypes
6
+ import re
6
7
  import shutil
7
8
  from collections.abc import Mapping, Sequence
8
9
  from pathlib import Path
@@ -13,6 +14,19 @@ from unique_sdk.cli.formatting import format_content_info
13
14
  from unique_sdk.cli.state import ShellState
14
15
  from unique_sdk.utils.file_io import download_content, upload_file
15
16
 
17
+ # A denial result has the shape ``<command>: permission denied[…]`` (the
18
+ # command token is lowercase, e.g. ``upload``/``ls``/``restore-version``) and
19
+ # is always the *first thing* in the result string — each command returns the
20
+ # denial verbatim (e.g. ``"upload: permission denied: …"``, ``"read:
21
+ # permission denied: …"``). Anchor at the start of the string only: with
22
+ # re.MULTILINE, ``^`` would match the start of any line, so a multi-line
23
+ # *success* output containing a line shaped like ``token: permission denied``
24
+ # mid-result (e.g. quoted document text) would trigger a false non-zero exit.
25
+ # Successful results also start with a capitalised past-tense verb ("Uploaded:",
26
+ # "Downloaded:", "Renamed", "Restored:"), so the lowercase-prefix anchor never
27
+ # misfires on them. See UN-21780.
28
+ _PERMISSION_DENIED_RE = re.compile(r"^[a-z][a-z0-9-]*: permission denied")
29
+
16
30
  _SUPPORTED_UPLOAD_MIME_TYPES = {
17
31
  ".pdf": "application/pdf",
18
32
  ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -42,15 +56,25 @@ def _normalize_unique_file_path(cwd: str, path: str) -> str:
42
56
  return "/" + "/".join(parts)
43
57
 
44
58
 
45
- def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
59
+ def _resolve_content_id(
60
+ state: ShellState, name_or_id: str, *, allow_chat_files: bool = True
61
+ ) -> tuple[str, str]:
46
62
  """Resolve a file name or content ID to (content_id, display_name).
47
63
 
48
64
  Accepts a content ID (cont_...), a file name in the current folder,
49
- or an absolute/relative Unique file path.
65
+ or an absolute/relative Unique file path. Pass ``allow_chat_files=False``
66
+ from destructive ops so the chat-attachment exemption (read-only intent)
67
+ can't be used to delete/rename out-of-scope content. See UN-21780.
50
68
  """
51
69
  if name_or_id.startswith("cont_"):
52
- if not state.is_content_within_workspace(name_or_id):
53
- raise ValueError("permission denied (outside workspace scope)")
70
+ if not state.is_content_within_workspace(
71
+ name_or_id, allow_chat_files=allow_chat_files
72
+ ):
73
+ raise ValueError(
74
+ f"permission denied: {name_or_id} is outside your task scope "
75
+ f"({state.scope_denial_hint()}). Use 'unique-cli search' or "
76
+ "'ls' within that scope instead."
77
+ )
54
78
  return name_or_id, name_or_id
55
79
 
56
80
  lookup_name = name_or_id
@@ -62,7 +86,32 @@ def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
62
86
  raise ValueError(f"File path must include a file name: {name_or_id}")
63
87
  if not folder_path:
64
88
  folder_path = "/"
65
- if not state.is_folder_target_within_workspace(folder_path):
89
+ # An active per-message filter replaces the static scopeIds for content
90
+ # access. Gate the *folder* against the navigable filter scope before
91
+ # Folder.get_info + the name scan: deferring solely to the per-id gate
92
+ # lets a path into an out-of-scope folder resolve the file and surface a
93
+ # task-scope "permission denied" instead of "File not found", leaking
94
+ # cross-boundary existence — the same oracle closed for bare filenames
95
+ # at root. The path check is structural (no API call), so it denies
96
+ # uniformly whether or not the file exists. See UN-21780.
97
+ if state.workspace_metadata_filter is not None:
98
+ # Only gate the folder when the filter actually constrains folders.
99
+ # A pure contentId scope has no navigable folders, so the document
100
+ # may live anywhere and the per-id gate below is authoritative —
101
+ # gating on folder here would wrongly deny it. When the filter does
102
+ # name folders, a path outside them is denied structurally (no API
103
+ # call), so an out-of-scope file can't leak existence via the per-id
104
+ # "permission denied" vs "File not found" distinction.
105
+ if (
106
+ state.navigable_folder_ids()
107
+ and not state.folder_path_allowed_by_metadata_filter(folder_path)
108
+ ):
109
+ raise ValueError(
110
+ f"permission denied: {name_or_id} is outside your task scope "
111
+ f"({state.scope_denial_hint()}). Use 'unique-cli search' or "
112
+ "'ls' within that scope instead."
113
+ )
114
+ elif not state.is_folder_target_within_workspace(folder_path):
66
115
  raise ValueError("permission denied (outside workspace scope)")
67
116
 
68
117
  info = unique_sdk.Folder.get_info(
@@ -73,8 +122,26 @@ def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
73
122
  scope_id = info.get("id")
74
123
  if not scope_id:
75
124
  raise ValueError(f"folder not found: {folder_path}")
76
- elif not state.is_within_workspace():
77
- raise ValueError("permission denied (outside workspace scope)")
125
+ elif scope_id is None:
126
+ # A bare file name with no folder context resolves via an *unparented*
127
+ # Content.get_infos scan across the whole knowledge base.
128
+ if state.workspace_metadata_filter is not None:
129
+ # With a per-message filter active, a whole-KB filename scan would
130
+ # discover documents outside the task scope before the per-id gate
131
+ # runs — and the deny-vs-not-found distinction is an existence/title
132
+ # oracle across the task boundary. That is broader than the
133
+ # ls/read-at-root gating intent, so require a bounded context
134
+ # instead. The cont_ fast-path, an explicit in-scope path, or a
135
+ # ``cd`` into an in-scope folder all still work. See UN-21780.
136
+ raise ValueError(
137
+ f"permission denied: {name_or_id} can't be resolved by name at "
138
+ f"the knowledge-base root while a task scope is active "
139
+ f"({state.scope_denial_hint()}). Use 'unique-cli search', cite "
140
+ "by content id, or 'cd' into an in-scope folder."
141
+ )
142
+ if not state.is_within_workspace():
143
+ # No per-message filter: defer to the static scopeIds boundary.
144
+ raise ValueError("permission denied (outside workspace scope)")
78
145
 
79
146
  params: dict[str, Any] = {}
80
147
  if scope_id:
@@ -98,7 +165,22 @@ def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
98
165
  title = info.get("title") or ""
99
166
  key = info.get("key") or ""
100
167
  if lookup_name in {title, key}:
101
- return info["id"], title or key
168
+ resolved_id = info["id"]
169
+ # Gate the *resolved* id too: resolving by file name or path
170
+ # must not bypass the per-message metadata-filter scope that
171
+ # the cont_ fast-path above enforces, and must honour the same
172
+ # read-only chat-file exemption (so rm/mv by name can't reach
173
+ # an out-of-scope attachment either). See UN-21780.
174
+ if not state.is_content_within_workspace(
175
+ resolved_id, allow_chat_files=allow_chat_files
176
+ ):
177
+ raise ValueError(
178
+ f"permission denied: {name_or_id} is outside your "
179
+ f"task scope ({state.scope_denial_hint()}). Use "
180
+ "'unique-cli search' or 'ls' within that scope "
181
+ "instead."
182
+ )
183
+ return resolved_id, title or key
102
184
 
103
185
  skip += len(content_infos)
104
186
 
@@ -221,7 +303,14 @@ def cmd_upload(
221
303
  upload file.pdf /abs/path/ -> into absolute path folder
222
304
  """
223
305
  dest = destination or "."
224
- if not state.is_folder_target_within_workspace(dest):
306
+ # A per-message metaDataFilter *replaces* the static scopeIds (read/search/
307
+ # ls honour the filter, not scopeIds), so the static-scope check must not
308
+ # over-deny an in-filter destination. When a filter is active the resolved
309
+ # scope id is gated against it below instead. See UN-21780.
310
+ if (
311
+ state.workspace_metadata_filter is None
312
+ and not state.is_folder_target_within_workspace(dest)
313
+ ):
225
314
  return "upload: permission denied (outside workspace scope)"
226
315
  try:
227
316
  path = Path(local_path).expanduser().resolve()
@@ -234,6 +323,21 @@ def cmd_upload(
234
323
  destination,
235
324
  )
236
325
 
326
+ # is_folder_target_within_workspace only enforces the static
327
+ # scopeIds; when the runner supplies a per-message metaDataFilter
328
+ # without scopeIds it treats every folder as writable. Gate the
329
+ # resolved destination against the per-message filter too, so uploads
330
+ # can't escape a task scope that read/download/ls/search honor.
331
+ # See UN-21780.
332
+ if (
333
+ state.workspace_metadata_filter is not None
334
+ and not state.folder_allowed_by_metadata_filter(scope_id)
335
+ ):
336
+ return (
337
+ "upload: permission denied: destination is outside your "
338
+ f"task scope ({state.scope_denial_hint()})."
339
+ )
340
+
237
341
  mime_type = _detect_upload_mime_type(path)
238
342
 
239
343
  result = upload_file(
@@ -286,6 +390,19 @@ def cmd_versions(
286
390
 
287
391
  def cmd_restore_version(state: ShellState, content_version_id: str) -> str:
288
392
  """Restore a file from an archived content version ID."""
393
+ # A per-message metaDataFilter is a hard task boundary, but the restore
394
+ # API only resolves a contentVersionId to its content *after* mutating,
395
+ # so an out-of-scope version can't be screened beforehand. Deny while a
396
+ # filter is active rather than allow an unverifiable mutation; reads stay
397
+ # gated regardless. The filter replaces the static scope for the turn, so
398
+ # check it *before* the static is_within_workspace() fallback — otherwise
399
+ # an out-of-static-scope cwd would surface the static denial (no task-scope
400
+ # hint) even though the filter is authoritative. See UN-21780.
401
+ if state.workspace_metadata_filter is not None:
402
+ return (
403
+ "restore-version: permission denied: cannot verify the target is "
404
+ f"within your task scope ({state.scope_denial_hint()})."
405
+ )
289
406
  if not state.is_within_workspace():
290
407
  return "restore-version: permission denied (outside workspace scope)"
291
408
  try:
@@ -338,13 +455,13 @@ def cmd_download(
338
455
 
339
456
  def cmd_rm(state: ShellState, name_or_id: str) -> str:
340
457
  """Delete a file by name or content ID."""
341
- if name_or_id.startswith("cont_"):
342
- if not state.is_content_within_workspace(name_or_id):
343
- return "rm: permission denied (outside workspace scope)"
344
- elif not state.is_within_workspace():
345
- return "rm: permission denied (outside workspace scope)"
346
458
  try:
347
- content_id, display_name = _resolve_content_id(state, name_or_id)
459
+ # Destructive: allow_chat_files=False so the chat-attachment read
460
+ # exemption can't be used to delete an out-of-scope file. Resolution
461
+ # also surfaces the task-scope hint on denial. See UN-21780.
462
+ content_id, display_name = _resolve_content_id(
463
+ state, name_or_id, allow_chat_files=False
464
+ )
348
465
  unique_sdk.Content.delete(
349
466
  user_id=state.config.user_id,
350
467
  company_id=state.config.company_id,
@@ -357,13 +474,13 @@ def cmd_rm(state: ShellState, name_or_id: str) -> str:
357
474
 
358
475
  def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
359
476
  """Rename a file."""
360
- if old_name.startswith("cont_"):
361
- if not state.is_content_within_workspace(old_name):
362
- return "mv: permission denied (outside workspace scope)"
363
- elif not state.is_within_workspace():
364
- return "mv: permission denied (outside workspace scope)"
365
477
  try:
366
- content_id, display_name = _resolve_content_id(state, old_name)
478
+ # Destructive: allow_chat_files=False so the chat-attachment read
479
+ # exemption can't be used to rename an out-of-scope file. Resolution
480
+ # also surfaces the task-scope hint on denial. See UN-21780.
481
+ content_id, display_name = _resolve_content_id(
482
+ state, old_name, allow_chat_files=False
483
+ )
367
484
  result = unique_sdk.Content.update(
368
485
  user_id=state.config.user_id,
369
486
  company_id=state.config.company_id,
@@ -373,3 +490,19 @@ def cmd_mv_file(state: ShellState, old_name: str, new_name: str) -> str:
373
490
  return f"Renamed: {display_name} -> {result.get('title', new_name)}\n{format_content_info(result)}"
374
491
  except (ValueError, unique_sdk.APIError) as e:
375
492
  return f"mv: {e}"
493
+
494
+
495
+ def is_permission_denied_output(output: str) -> bool:
496
+ """Return ``True`` when a file-op result is a permission/scope denial.
497
+
498
+ Lets the one-shot dispatcher exit non-zero so shell ``&&`` chains stop on
499
+ an out-of-scope content access instead of continuing as if it succeeded.
500
+
501
+ Matches the denial only when it is the *form of the result* — a
502
+ ``<command>: permission denied`` line — rather than anywhere the substring
503
+ appears. Denials are emitted as ``"<cmd>: permission denied[ : …]"`` (e.g.
504
+ ``"upload: permission denied: …"``); anchoring on that shape avoids a
505
+ false non-zero exit when a successful result happens to contain the phrase
506
+ (e.g. a filename or document text). See UN-21780.
507
+ """
508
+ return bool(_PERMISSION_DENIED_RE.search(output))