tenseleyflow/loader / fac914c

Browse files

Track relative bash audit paths

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
fac914c41aacb468f2ecd6adfebb2aabd42cd5cf
Parents
9c5b609
Tree
7d1f567

2 changed files

StatusFile+-
M src/loader/runtime/hooks.py 50 9
M tests/test_permissions.py 135 0
src/loader/runtime/hooks.pymodified
@@ -505,19 +505,15 @@ def _is_read_only_bash(command: str) -> bool:
505505
         return False
506506
     if any(fragment in normalized for fragment in _MUTATING_BASH_FRAGMENTS):
507507
         return False
508
-    try:
509
-        argv = shlex.split(normalized)
510
-    except ValueError:
511
-        return False
508
+    argv, _ = _extract_bash_command_context(normalized)
512509
     if not argv:
513510
         return False
514511
     return argv[0] in _READ_ONLY_BASH_PREFIXES
515512
 
516513
 
517514
 def _extract_bash_paths(command: str) -> list[str]:
518
-    try:
519
-        argv = shlex.split(command)
520
-    except ValueError:
515
+    argv, base_dir = _extract_bash_command_context(command)
516
+    if not argv:
521517
         return []
522518
     observed: list[str] = []
523519
     for token in argv[1:]:
@@ -526,9 +522,47 @@ def _extract_bash_paths(command: str) -> list[str]:
526522
             continue
527523
         if candidate.startswith(("/", "~")):
528524
             observed.append(candidate)
525
+            continue
526
+        if base_dir and (
527
+            "/" in candidate
528
+            or candidate.startswith(("./", "../"))
529
+            or candidate in {".", ".."}
530
+        ):
531
+            observed.append(str(base_dir / candidate))
532
+    if not observed and base_dir is not None:
533
+        observed.append(str(base_dir))
529534
     return observed
530535
 
531536
 
537
+def _extract_bash_command_context(command: str) -> tuple[list[str], Path | None]:
538
+    try:
539
+        argv = shlex.split(command)
540
+    except ValueError:
541
+        return [], None
542
+    if not argv:
543
+        return [], None
544
+    base_dir = _extract_bash_base_dir(argv)
545
+    if base_dir is None:
546
+        return argv, None
547
+    if len(argv) >= 4 and argv[2] in {"&&", ";"}:
548
+        return argv[3:], base_dir
549
+    return argv, base_dir
550
+
551
+
552
+def _extract_bash_base_dir(argv: list[str]) -> Path | None:
553
+    if len(argv) < 2 or argv[0] != "cd":
554
+        return None
555
+    candidate = argv[1].strip()
556
+    if not candidate:
557
+        return None
558
+    if not candidate.startswith(("/", "~")):
559
+        return None
560
+    try:
561
+        return Path(candidate).expanduser()
562
+    except (OSError, RuntimeError, ValueError):
563
+        return None
564
+
565
+
532566
 def _derive_search_anchor(search_path: str, pattern: str) -> str:
533567
     normalized_search_path = str(search_path or "").strip()
534568
     normalized_pattern = str(pattern or "").strip()
@@ -592,6 +626,13 @@ def _is_mutating_bash(command: str) -> bool:
592626
     return argv[0] in {"touch", "mkdir", "rm", "mv", "cp", "chmod", "chown"}
593627
 
594628
 
629
+def _tool_call_is_effective_mutation(tool_call: ToolCall) -> bool:
630
+    if tool_call.name != "bash":
631
+        return tool_call.name in _MUTATION_TOOLS
632
+    command = str(tool_call.arguments.get("command", "")).strip()
633
+    return _is_mutating_bash(command)
634
+
635
+
595636
 def _repair_declared_output_paths(repair: Any, *, project_root: Path) -> set[str]:
596637
     declared_outputs: set[str] = set()
597638
     for root in getattr(repair, "allowed_roots", ()) or ():
@@ -735,7 +776,7 @@ class ActiveRepairScopeHook(BaseToolHook):
735776
     async def post_tool_use(self, context: HookContext) -> HookResult:
736777
         if context.source == "verification":
737778
             return HookResult()
738
-        if context.tool_call.name in _MUTATION_TOOLS:
779
+        if _tool_call_is_effective_mutation(context.tool_call):
739780
             self._reset_source_of_truth_scope()
740781
             return HookResult()
741782
         if context.tool_call.name not in _OBSERVATION_TOOLS:
@@ -1033,7 +1074,7 @@ class LateReferenceDriftHook(BaseToolHook):
10331074
         return False
10341075
 
10351076
     async def post_tool_use(self, context: HookContext) -> HookResult:
1036
-        if context.tool_call.name in _MUTATION_TOOLS:
1077
+        if _tool_call_is_effective_mutation(context.tool_call):
10371078
             self._reset_completed_scope_state()
10381079
             return HookResult()
10391080
         if context.tool_call.name not in _OBSERVATION_TOOLS:
tests/test_permissions.pymodified
@@ -1806,6 +1806,141 @@ async def test_late_reference_drift_hook_blocks_excessive_post_build_self_audits
18061806
     assert "post-build audit loop" in blocked.message
18071807
 
18081808
 
1809
+@pytest.mark.asyncio
1810
+async def test_late_reference_drift_hook_blocks_relative_bash_reference_reads_after_artifacts_exist(
1811
+    temp_dir: Path,
1812
+) -> None:
1813
+    registry = create_default_registry(temp_dir)
1814
+    policy = build_permission_policy(
1815
+        active_mode=PermissionMode.WORKSPACE_WRITE,
1816
+        workspace_root=temp_dir,
1817
+        tool_requirements=registry.get_tool_requirements(),
1818
+    )
1819
+    dod_store = DefinitionOfDoneStore(temp_dir)
1820
+    dod = create_definition_of_done("Create a multi-file guide from a reference")
1821
+    dod.status = "in_progress"
1822
+    plan_path = temp_dir / "implementation.md"
1823
+    plan_path.write_text(
1824
+        "\n".join(
1825
+            [
1826
+                "# Implementation Plan",
1827
+                "",
1828
+                "## File Changes",
1829
+                f"- `{temp_dir / 'guide'}`",
1830
+                f"- `{temp_dir / 'guide' / 'chapters'}`",
1831
+                f"- `{temp_dir / 'guide' / 'index.html'}`",
1832
+                f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1833
+                f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1834
+                "",
1835
+            ]
1836
+        )
1837
+    )
1838
+    dod.implementation_plan = str(plan_path)
1839
+    guide_dir = temp_dir / "guide" / "chapters"
1840
+    guide_dir.mkdir(parents=True, exist_ok=True)
1841
+    (temp_dir / "guide" / "index.html").write_text("index")
1842
+    (guide_dir / "01-getting-started.html").write_text("one")
1843
+    (guide_dir / "02-installation.html").write_text("two")
1844
+    dod_path = dod_store.save(dod)
1845
+    session = FakeSession(active_dod_path=str(dod_path), messages=[])
1846
+    hook = LateReferenceDriftHook(
1847
+        dod_store=dod_store,
1848
+        project_root=temp_dir,
1849
+        session=session,
1850
+    )
1851
+
1852
+    result = await hook.pre_tool_use(
1853
+        HookContext(
1854
+            tool_call=ToolCall(
1855
+                id="bash-relative-reference-1",
1856
+                name="bash",
1857
+                arguments={
1858
+                    "command": f"cd {temp_dir} && ls -la reference/"
1859
+                },
1860
+            ),
1861
+            tool=registry.get("bash"),
1862
+            registry=registry,
1863
+            permission_policy=policy,
1864
+            source="verification",
1865
+        )
1866
+    )
1867
+
1868
+    assert result.decision == HookDecision.DENY
1869
+    assert result.terminal_state == "blocked"
1870
+    assert result.message is not None
1871
+    assert "completed artifact set scope" in result.message
1872
+
1873
+
1874
+@pytest.mark.asyncio
1875
+async def test_late_reference_drift_hook_blocks_relative_bash_post_build_audit_loop(
1876
+    temp_dir: Path,
1877
+) -> None:
1878
+    registry = create_default_registry(temp_dir)
1879
+    policy = build_permission_policy(
1880
+        active_mode=PermissionMode.WORKSPACE_WRITE,
1881
+        workspace_root=temp_dir,
1882
+        tool_requirements=registry.get_tool_requirements(),
1883
+    )
1884
+    dod_store = DefinitionOfDoneStore(temp_dir)
1885
+    dod = create_definition_of_done("Create a multi-file guide from a reference")
1886
+    dod.status = "in_progress"
1887
+    plan_path = temp_dir / "implementation.md"
1888
+    plan_path.write_text(
1889
+        "\n".join(
1890
+            [
1891
+                "# Implementation Plan",
1892
+                "",
1893
+                "## File Changes",
1894
+                f"- `{temp_dir / 'guide' / 'index.html'}`",
1895
+                f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1896
+                f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1897
+                "",
1898
+            ]
1899
+        )
1900
+    )
1901
+    dod.implementation_plan = str(plan_path)
1902
+    guide_dir = temp_dir / "guide" / "chapters"
1903
+    guide_dir.mkdir(parents=True, exist_ok=True)
1904
+    (temp_dir / "guide" / "index.html").write_text("<h1>Guide</h1>\n")
1905
+    (guide_dir / "01-getting-started.html").write_text("<h1>One</h1>\n")
1906
+    (guide_dir / "02-installation.html").write_text("<h1>Two</h1>\n")
1907
+    dod_path = dod_store.save(dod)
1908
+    session = FakeSession(active_dod_path=str(dod_path), messages=[])
1909
+    hook = LateReferenceDriftHook(
1910
+        dod_store=dod_store,
1911
+        project_root=temp_dir,
1912
+        session=session,
1913
+    )
1914
+
1915
+    def make_context(index: int) -> HookContext:
1916
+        return HookContext(
1917
+            tool_call=ToolCall(
1918
+                id=f"bash-relative-audit-{index}",
1919
+                name="bash",
1920
+                arguments={
1921
+                    "command": f"cd {temp_dir} && ls -la guide/chapters/"
1922
+                },
1923
+            ),
1924
+            tool=registry.get("bash"),
1925
+            registry=registry,
1926
+            permission_policy=policy,
1927
+            source="verification",
1928
+        )
1929
+
1930
+    for index in range(1, 5):
1931
+        context = make_context(index)
1932
+        result = await hook.pre_tool_use(context)
1933
+        assert result.decision == HookDecision.CONTINUE
1934
+        await hook.post_tool_use(context)
1935
+
1936
+    blocked = await hook.pre_tool_use(make_context(5))
1937
+
1938
+    assert blocked.decision == HookDecision.DENY
1939
+    assert blocked.terminal_state == "blocked"
1940
+    assert blocked.message is not None
1941
+    assert "post-build audit loop" in blocked.message
1942
+
1943
+
18091944
 @pytest.mark.asyncio
18101945
 async def test_late_reference_drift_hook_does_not_treat_empty_output_dir_as_complete_artifact_set(
18111946
     temp_dir: Path,