Track relative bash audit paths
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
fac914c41aacb468f2ecd6adfebb2aabd42cd5cf- Parents
-
9c5b609 - Tree
7d1f567
fac914c
fac914c41aacb468f2ecd6adfebb2aabd42cd5cf9c5b609
7d1f567| Status | File | + | - |
|---|---|---|---|
| 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: | ||
| 505 | 505 | return False |
| 506 | 506 | if any(fragment in normalized for fragment in _MUTATING_BASH_FRAGMENTS): |
| 507 | 507 | return False |
| 508 | - try: | |
| 509 | - argv = shlex.split(normalized) | |
| 510 | - except ValueError: | |
| 511 | - return False | |
| 508 | + argv, _ = _extract_bash_command_context(normalized) | |
| 512 | 509 | if not argv: |
| 513 | 510 | return False |
| 514 | 511 | return argv[0] in _READ_ONLY_BASH_PREFIXES |
| 515 | 512 | |
| 516 | 513 | |
| 517 | 514 | 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: | |
| 521 | 517 | return [] |
| 522 | 518 | observed: list[str] = [] |
| 523 | 519 | for token in argv[1:]: |
@@ -526,9 +522,47 @@ def _extract_bash_paths(command: str) -> list[str]: | ||
| 526 | 522 | continue |
| 527 | 523 | if candidate.startswith(("/", "~")): |
| 528 | 524 | 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)) | |
| 529 | 534 | return observed |
| 530 | 535 | |
| 531 | 536 | |
| 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 | + | |
| 532 | 566 | def _derive_search_anchor(search_path: str, pattern: str) -> str: |
| 533 | 567 | normalized_search_path = str(search_path or "").strip() |
| 534 | 568 | normalized_pattern = str(pattern or "").strip() |
@@ -592,6 +626,13 @@ def _is_mutating_bash(command: str) -> bool: | ||
| 592 | 626 | return argv[0] in {"touch", "mkdir", "rm", "mv", "cp", "chmod", "chown"} |
| 593 | 627 | |
| 594 | 628 | |
| 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 | + | |
| 595 | 636 | def _repair_declared_output_paths(repair: Any, *, project_root: Path) -> set[str]: |
| 596 | 637 | declared_outputs: set[str] = set() |
| 597 | 638 | for root in getattr(repair, "allowed_roots", ()) or (): |
@@ -735,7 +776,7 @@ class ActiveRepairScopeHook(BaseToolHook): | ||
| 735 | 776 | async def post_tool_use(self, context: HookContext) -> HookResult: |
| 736 | 777 | if context.source == "verification": |
| 737 | 778 | return HookResult() |
| 738 | - if context.tool_call.name in _MUTATION_TOOLS: | |
| 779 | + if _tool_call_is_effective_mutation(context.tool_call): | |
| 739 | 780 | self._reset_source_of_truth_scope() |
| 740 | 781 | return HookResult() |
| 741 | 782 | if context.tool_call.name not in _OBSERVATION_TOOLS: |
@@ -1033,7 +1074,7 @@ class LateReferenceDriftHook(BaseToolHook): | ||
| 1033 | 1074 | return False |
| 1034 | 1075 | |
| 1035 | 1076 | 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): | |
| 1037 | 1078 | self._reset_completed_scope_state() |
| 1038 | 1079 | return HookResult() |
| 1039 | 1080 | 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 | ||
| 1806 | 1806 | assert "post-build audit loop" in blocked.message |
| 1807 | 1807 | |
| 1808 | 1808 | |
| 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 | + | |
| 1809 | 1944 | @pytest.mark.asyncio |
| 1810 | 1945 | async def test_late_reference_drift_hook_does_not_treat_empty_output_dir_as_complete_artifact_set( |
| 1811 | 1946 | temp_dir: Path, |