Skip to content

Commit cc3496a

Browse files
authored
Merge pull request #1949 from Open-Source-Legal/claude/intelligent-cerf-s2gUQ
Corpus file-tool review polish (PR #1940 follow-up)
2 parents fcc741e + a310924 commit cc3496a

7 files changed

Lines changed: 176 additions & 43 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
- **Corpus file-tool review polish (PR #1940 follow-up, issue #1943 items 3–5).**
2+
- **`require_user` helper (`opencontractserver/llms/tools/core_tools/_helpers.py`).**
3+
The 5-line "no user injected / id not found → `PermissionError`" guard that
4+
`rename_document` and `delete_document` each repeated is now a single
5+
`require_user(user_id, caller)` helper (companion to the existing
6+
`get_user_or_none`), preserving the same per-caller error messages.
7+
Read-only tools keep using `get_user_or_none` (which tolerates anonymous
8+
access) (item 4).
9+
- **`delete_document` tool description (`opencontractserver/llms/tools/tool_registry.py`).**
10+
The description now spells out that deletion needs the corpus DELETE tier —
11+
*higher* than the write permission `rename_document`/`move_document` use —
12+
and instructs the LLM to report a `permission denied` failure rather than
13+
retry-loop on it. A WRITE-but-not-DELETE user still passes the coarse
14+
framework `requires_write_permission` gate and is correctly rejected by
15+
`DocumentLifecycleService.soft_delete_document`; tightening this to a
16+
dedicated `requires_delete_permission` flag (so the tool is filtered out
17+
before such a user ever sees it) remains a tracked follow-up (item 3).
18+
- **Direct unit test for `sanitize_corpus_filename`**
19+
(`opencontractserver/tests/test_shared_utils.py`). The canonical
20+
corpus-filename sanitiser was only exercised indirectly through the rename
21+
integration paths; it now has a focused test covering empty/None →
22+
fallback, all-special-chars → underscores, path-separator collapse (no
23+
directory traversal), no run-collapsing, leading-dot preservation, and
24+
truncation at `MAX_FILENAME_LENGTH` (item 5).
25+
- **Not changed (item 2):** `CorpusDocumentService._check_document_in_corpus`
26+
was kept private. It was deliberately renamed from public to underscore-
27+
prefixed in PR #1685 to signal its "NO PERMISSION CHECK" contract; its
28+
cross-service callers all live inside `corpuses.services` and gate corpus
29+
READ upstream, so the leading underscore (service-internal use) is correct.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
- **`rename_document` agent tool: dropped an extra query and closed a status race (PR #1940 follow-up, issue #1943 item 1).**
2+
`FolderDocumentService.rename_document`
3+
(`opencontractserver/corpuses/services/folder_documents.py`) now returns a
4+
4-tuple `(success, error, new_path, changed)` — the new `changed` boolean is
5+
`True` only for a real rename and `False` for a no-op (sanitised name already
6+
matched) or any failure. The tool
7+
(`opencontractserver/llms/tools/core_tools/documents.py`) previously
8+
snapshotted the pre-rename `DocumentPath.path` and compared it to the
9+
service's returned path to label the response `"renamed"` vs `"unchanged"`.
10+
That extra `DocumentPath` read is gone, and so is the snapshot-vs-service race
11+
window where a concurrent rename could mislabel the `status` (the underlying
12+
data was always safe — the service's `select_for_update` guarantees that;
13+
only the tool-layer status string could drift).

opencontractserver/corpuses/services/folder_documents.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def rename_document(
327327
new_name: str,
328328
*,
329329
request: Any = None,
330-
) -> tuple[bool, str, str | None]:
330+
) -> tuple[bool, str, str | None, bool]:
331331
"""Rename a document's file within a corpus (change the filename only).
332332
333333
Creates a new immutable :class:`DocumentPath` history node whose
@@ -358,10 +358,14 @@ def rename_document(
358358
new_name: Desired new filename (last path segment only).
359359
360360
Returns:
361-
``(success, error_message, new_path)``. ``new_path`` is the
361+
``(success, error_message, new_path, changed)``. ``new_path`` is the
362362
resulting path string on success (possibly disambiguated, e.g.
363-
``/Legal/Q3_Summary_1.pdf``) and ``None`` on failure. A no-op
364-
rename (the name is unchanged) returns ``(True, "", <current path>)``.
363+
``/Legal/Q3_Summary_1.pdf``) and ``None`` on failure. ``changed`` is
364+
``True`` only when the filename actually changed and ``False`` for a
365+
no-op (the sanitised name already matched the current filename) or
366+
any failure — letting callers distinguish a real rename from a no-op
367+
without re-reading the pre-rename path. A no-op rename returns
368+
``(True, "", <current path>, False)``.
365369
366370
Validations:
367371
- User has corpus UPDATE permission
@@ -377,14 +381,15 @@ def rename_document(
377381
False,
378382
"Permission denied: You do not have write access to this corpus",
379383
None,
384+
False,
380385
)
381386

382387
# Validate document belongs to corpus
383388
if not CorpusDocumentService._check_document_in_corpus(document, corpus):
384-
return False, "Document does not belong to this corpus", None
389+
return False, "Document does not belong to this corpus", None, False
385390

386391
if not new_name or not new_name.strip():
387-
return False, "New name must not be empty", None
392+
return False, "New name must not be empty", None, False
388393

389394
sanitized = sanitize_corpus_filename(new_name.strip())
390395

@@ -407,7 +412,7 @@ def rename_document(
407412
)
408413

409414
if not current:
410-
return False, "No active document path found", None
415+
return False, "No active document path found", None, False
411416

412417
# Split the current path into "<directory>/" + "<filename>".
413418
# Stored paths always start with "/", so rpartition yields a
@@ -423,7 +428,7 @@ def rename_document(
423428

424429
# No-op: the file already has this name.
425430
if new_filename == current_filename:
426-
return True, "", current.path
431+
return True, "", current.path, False
427432

428433
base_path = f"{directory}{new_filename}"
429434

@@ -436,7 +441,7 @@ def rename_document(
436441
user=user,
437442
)
438443
except ValueError as exc:
439-
return False, str(exc), None
444+
return False, str(exc), None, False
440445
except IntegrityError as exc:
441446
logger.warning(
442447
"IntegrityError renaming document %s in corpus %s after "
@@ -445,7 +450,7 @@ def rename_document(
445450
corpus.id,
446451
exc,
447452
)
448-
return False, f"{PATH_CONFLICT_MSG}, please retry: {exc}", None
453+
return False, f"{PATH_CONFLICT_MSG}, please retry: {exc}", None, False
449454

450455
logger.info(
451456
"Renamed document %s in corpus %s from %r to %r by user %s",
@@ -455,7 +460,7 @@ def rename_document(
455460
chosen_path,
456461
user.id,
457462
)
458-
return True, "", chosen_path
463+
return True, "", chosen_path, True
459464

460465
@classmethod
461466
def move_documents_to_folder(

opencontractserver/llms/tools/core_tools/_helpers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,31 @@ def get_user_or_none(user_id: int | None):
5454
return None
5555

5656

57+
def require_user(user_id: int | None, caller: str):
58+
"""Resolve ``user_id`` to a ``User`` or raise ``PermissionError``.
59+
60+
The write tools (e.g. ``rename_document`` / ``delete_document``) require an
61+
authenticated, existing user. This collapses the two distinct guard
62+
branches those tools repeated into one helper, preserving the same
63+
``caller``-specific messages for each failure mode:
64+
65+
* ``user_id is None`` (no user injected at all) →
66+
``"<caller> requires an authenticated user."``
67+
* a stale/unknown id (no matching row) → ``"User <id> not found."``
68+
69+
Read-only tools that tolerate anonymous access keep using
70+
:func:`get_user_or_none` instead (it returns ``None`` rather than raising).
71+
72+
Return type is intentionally inferred — see :func:`get_user_or_none`.
73+
"""
74+
if user_id is None:
75+
raise PermissionError(f"{caller} requires an authenticated user.")
76+
user = get_user_or_none(user_id)
77+
if user is None:
78+
raise PermissionError(f"User {user_id} not found.")
79+
return user
80+
81+
5782
def clamp_limit(limit: int | None, default: int, maximum: int) -> int:
5883
"""Clamp a caller-supplied ``limit`` into ``[1, maximum]``.
5984

opencontractserver/llms/tools/core_tools/documents.py

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@
4444
from opencontractserver.corpuses.models import Corpus
4545
from opencontractserver.documents.models import Document, DocumentPath
4646

47-
from ._helpers import _db_sync_to_async, clamp_limit, get_user_or_none
47+
from ._helpers import (
48+
_db_sync_to_async,
49+
clamp_limit,
50+
get_user_or_none,
51+
require_user,
52+
)
4853

4954
logger = logging.getLogger(__name__)
5055

@@ -323,11 +328,7 @@ def rename_document(
323328
ValueError: If the document/corpus is not accessible or the rename
324329
fails.
325330
"""
326-
if user_id is None:
327-
raise PermissionError("rename_document requires an authenticated user.")
328-
user = get_user_or_none(user_id)
329-
if user is None:
330-
raise PermissionError(f"User {user_id} not found.")
331+
user = require_user(user_id, "rename_document")
331332

332333
corpus = Corpus.objects.visible_to_user(user).filter(pk=corpus_id).first()
333334
if corpus is None:
@@ -341,19 +342,13 @@ def rename_document(
341342
f"Document with id={document_id} does not exist or is not accessible."
342343
)
343344

344-
# Snapshot the pre-rename path so we can tell a real rename from a no-op
345-
# (the service returns the unchanged path for both — see its docstring).
346-
previous_path = (
347-
DocumentPath.objects.filter(
348-
document=document, corpus=corpus, is_current=True, is_deleted=False
349-
)
350-
.values_list("path", flat=True)
351-
.first()
352-
)
353-
354345
from opencontractserver.corpuses.services import FolderDocumentService
355346

356-
success, error, new_path = FolderDocumentService.rename_document(
347+
# The service reports whether the filename actually changed via ``changed``,
348+
# so the tool no longer snapshots the pre-rename path to infer a no-op — this
349+
# drops an extra query and closes the snapshot/service race that could mislabel
350+
# a concurrent rename's status.
351+
success, error, new_path, changed = FolderDocumentService.rename_document(
357352
user=user,
358353
document=document,
359354
corpus=corpus,
@@ -362,18 +357,16 @@ def rename_document(
362357
if not success:
363358
raise ValueError(f"Rename failed: {error}")
364359

365-
unchanged = new_path == previous_path
366360
return {
367-
"status": "unchanged" if unchanged else "renamed",
361+
"status": "renamed" if changed else "unchanged",
368362
"document_id": document_id,
369363
"corpus_id": corpus_id,
370364
"path": new_path,
371365
"message": (
372-
f"Document {document_id} already named '{new_path}' in corpus "
366+
f"Document {document_id} renamed to '{new_path}' in corpus {corpus_id}."
367+
if changed
368+
else f"Document {document_id} already named '{new_path}' in corpus "
373369
f"{corpus_id}; no change made."
374-
if unchanged
375-
else f"Document {document_id} renamed to '{new_path}' in "
376-
f"corpus {corpus_id}."
377370
),
378371
}
379372

@@ -425,11 +418,7 @@ def delete_document(
425418
ValueError: If the document/corpus is not accessible or the delete
426419
fails.
427420
"""
428-
if user_id is None:
429-
raise PermissionError("delete_document requires an authenticated user.")
430-
user = get_user_or_none(user_id)
431-
if user is None:
432-
raise PermissionError(f"User {user_id} not found.")
421+
user = require_user(user_id, "delete_document")
433422

434423
corpus = Corpus.objects.visible_to_user(user).filter(pk=corpus_id).first()
435424
if corpus is None:

opencontractserver/llms/tools/tool_registry.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,13 @@ def to_dict(self) -> dict:
588588
"Soft-delete a document from the current corpus (move it to the "
589589
"corpus trash). The document is NOT permanently erased — it keeps "
590590
"its history and can be restored from the corpus trash. Requires "
591-
"delete permission on the corpus."
591+
"the corpus DELETE permission, which is a HIGHER tier than the "
592+
"write/update permission the other file tools (rename_document, "
593+
"move_document) need: a user may be able to rename or move files "
594+
"yet still lack delete rights. If this tool fails with a "
595+
"'permission denied' error, the user cannot delete in this corpus "
596+
"— report that and do NOT retry, as repeating the call will keep "
597+
"failing."
592598
),
593599
category=ToolCategory.CORPUS,
594600
requires_corpus=True,
@@ -598,8 +604,11 @@ def to_dict(self) -> dict:
598604
# the framework layer; the authoritative DELETE check lives in
599605
# ``DocumentLifecycleService.soft_delete_document`` (defense in depth),
600606
# so a WRITE-but-not-DELETE user passes this gate and is then correctly
601-
# rejected by the service. If a ``requires_delete_permission`` flag is
602-
# ever added, switch this entry to it.
607+
# rejected by the service. The description above tells the LLM not to
608+
# retry-loop on that rejection. Tightening this to a dedicated
609+
# ``requires_delete_permission`` flag (so WRITE-only users never see the
610+
# tool) is tracked as a near-term follow-up; switch this entry to it
611+
# when that flag lands.
603612
requires_write_permission=True,
604613
parameters=(("document_id", "ID of the document to delete", True),),
605614
),

opencontractserver/tests/test_shared_utils.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from django.test import TestCase, override_settings
1111

12+
from opencontractserver.constants.document_processing import MAX_FILENAME_LENGTH
1213
from opencontractserver.shared.db_utils import table_has_column
1314
from opencontractserver.shared.defaults import (
1415
create_model_icon_path,
@@ -25,7 +26,10 @@
2526
sanitize_slug,
2627
validate_user_slug_or_raise,
2728
)
28-
from opencontractserver.shared.utils import calc_oc_file_path
29+
from opencontractserver.shared.utils import (
30+
calc_oc_file_path,
31+
sanitize_corpus_filename,
32+
)
2933

3034

3135
class TestDefaultFunctions(TestCase):
@@ -324,6 +328,65 @@ def test_filename_with_uuid(self):
324328
self.assertEqual(result, "uploadfiles/exports/abc-123-def.pdf")
325329

326330

331+
class TestSanitizeCorpusFilename(TestCase):
332+
"""Direct unit tests for shared/utils.py sanitize_corpus_filename.
333+
334+
This is the canonical sanitiser for the filename segment of a
335+
``DocumentPath.path`` (shared by ``Corpus.add_document``, the text-import
336+
tool and ``FolderDocumentService.rename_document``). It is exercised
337+
indirectly through the rename integration paths, but its invariants —
338+
fallback on empty, special-chars-to-underscore, no directory traversal,
339+
truncation, and leading-dot preservation — deserve an explicit test.
340+
"""
341+
342+
def test_allowed_chars_pass_through(self):
343+
# Alphanumerics plus -, _ and . survive unchanged.
344+
self.assertEqual(
345+
sanitize_corpus_filename("Report-v2_final.pdf"), "Report-v2_final.pdf"
346+
)
347+
348+
def test_empty_input_uses_fallback(self):
349+
self.assertEqual(sanitize_corpus_filename(""), "untitled")
350+
351+
def test_none_input_uses_fallback(self):
352+
# ``(name or "")`` coerces None before slicing, so None -> fallback too.
353+
self.assertEqual(sanitize_corpus_filename(None), "untitled")
354+
355+
def test_custom_fallback(self):
356+
self.assertEqual(sanitize_corpus_filename("", fallback="doc"), "doc")
357+
358+
def test_all_special_chars_become_underscores(self):
359+
# Disallowed chars are replaced (never dropped), so an all-special name
360+
# yields underscores, not the fallback.
361+
self.assertEqual(sanitize_corpus_filename("!!!!"), "____")
362+
363+
def test_path_separators_collapse_to_underscore(self):
364+
# Slashes (fwd and back) map to "_" so a sanitised name can never
365+
# traverse directories.
366+
self.assertEqual(sanitize_corpus_filename("a/b\\c"), "a_b_c")
367+
368+
def test_runs_are_not_collapsed(self):
369+
# Each disallowed char maps to its own underscore (documented: runs are
370+
# intentionally NOT collapsed to a single separator).
371+
self.assertEqual(sanitize_corpus_filename("My File"), "My__File")
372+
373+
def test_leading_dot_preserved(self):
374+
# A leading dot is an allowed char, so dotfiles keep their dot.
375+
self.assertEqual(sanitize_corpus_filename(".gitignore"), ".gitignore")
376+
377+
def test_truncated_to_max_length(self):
378+
result = sanitize_corpus_filename("a" * (MAX_FILENAME_LENGTH + 50))
379+
self.assertEqual(len(result), MAX_FILENAME_LENGTH)
380+
self.assertEqual(result, "a" * MAX_FILENAME_LENGTH)
381+
382+
def test_truncation_happens_before_sanitisation(self):
383+
# The raw input is sliced to MAX_FILENAME_LENGTH first, then each
384+
# surviving char is mapped — so an over-long all-special string yields
385+
# exactly MAX_FILENAME_LENGTH underscores, not more.
386+
result = sanitize_corpus_filename("*" * (MAX_FILENAME_LENGTH + 10))
387+
self.assertEqual(result, "_" * MAX_FILENAME_LENGTH)
388+
389+
327390
class TestVectorSearchMixinDimensionMapping(TestCase):
328391
"""Tests for VectorSearchViaEmbeddingMixin._dimension_to_field mapping."""
329392

0 commit comments

Comments
 (0)