mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
v7next F1: domain D04 - registry and tool_access split, proof-green; core/typed-result organs hot-deferred
Module side: 4 of 8 D04 owners are byte-identical to the reference and merge base (protected_artifacts, tool_policy, tools/__init__, tool_discovery), one is pure upstream drift (tool_capabilities - upstream bytes stand), one keeps upstream bytes because its reference delta is the typed-dispatch cutover (extension_dispatch, rows 187/188 deferred). The two splits land from TIP bytes with the transplant tool's triple proof green on every symbol (ast=tokens=bytes, leaf_invariants=[], exit 0): - tools/registry.py 3960 -> 2686 (PROTECTED file: pure byte-preserving span relocation + one re-export block + noqa on historical imports - proven by line-accounting audit): tool_context (2 symbols), tool_catalog (1), tool_resolution (28), registry_guards (16), registry_guard_process (27). 13 spans were byte-falsified as oracle copy sources by pure upstream drift and re-emitted from tip bytes; 5 rows whose reference destination carries typed-result semantics moved their TIP bodies verbatim (typed deltas ride with F2). HOT-DEFERRED with evidence: registry_core.py (ToolRegistry is a 2252-line class; the reference slimmed it via 17 method->function extractions, which are not byte-preserving), tool_result.py (32/33 symbols are the D02 typed organ, absent at tip). - tool_access.py 1591 -> 782: tool_access_types (14), tool_access_paths (10), tool_access_roots (9), tool_access_user_files (8); 39/41 spans byte-equal to the reference, _skill_payload_base and ResolvedResourceBinding re-emitted from tip (upstream refactor/field would have been reverted by an oracle copy). The D1 mirror-path defect travels UNFIXED per lane orders. Protection closure (protective-only, mirrors the reference and the tree's own LC2 parity rule): the five landed registry leaves join SAFETY_CRITICAL_PATHS and HOT_CODE_PATHS - code that moved out of the protected, hot registry keeps both labels; pinned by the new parity test. Test side: tests/test_tool_capabilities.py 1991 -> 681 splits into 4 siblings per ledger rows 784-825 from tip bytes (34/42 spans oracle-equal, 8 re-emitted from tip); lossless 61==61 test functions, tree-wide AST dup scan clean. Pins carried with disclosed adaptations: test_tool_owner_facades.py (+alias_for row), test_tool_access_extraction.py (4 adaptations in docstring), tool_resolution identity test appended to test_workspace_authority_binding.py (typed companion deliberately not carried). size-ratchet manifest regenerated with the official tool (test_tool_capabilities leaves GIANT_PATHS; no new band entries); ratchet lane 5 passed; ruff F clean; 90+518+586+257 tests green in isolation at the lane base; HEAD held through every pytest run. Ledger corrections: docs/v7next/LEDGER_CORRECTIONS.md D04 section, entries 1-11. (cherry picked from commit 2321514369b6f003e92bcd84e6a9a7efda6857df)
This commit is contained in:
parent
ec2cc3d188
commit
22c1473117
23 changed files with 4646 additions and 3553 deletions
|
|
@ -255,3 +255,105 @@ with evidence, found lane by lane. Applied to the campaign's carried ledger at F
|
|||
resolved unilaterally); candidate row for the carried ledger at F5.
|
||||
Disjoint upstream drift a23e12b1 (push_to_remote test retargeted to
|
||||
`_git_network_bounded`) stands.
|
||||
## From the D04 lane (base d830cdba, 2026-08-30)
|
||||
1. Registry-split rows RE-PROVEN against tip bytes for the four landed tools/
|
||||
leaves (tool_context, tool_catalog, tool_resolution, registry_guards,
|
||||
registry_guard_process — 74 symbols): 61 spans byte-identical between the
|
||||
reference leaves and `git show HEAD:ouroboros/tools/registry.py`; 13 spans
|
||||
BYTE-FALSIFIED as copy sources by PURE UPSTREAM DRIFT (oracle==merge-base,
|
||||
tip moved): _prepare_public_builtin_args, _executor_backend_candidate_allowed,
|
||||
_authorized_managed_update_resolver (404B -> 1843B hardening), _disabled_tools,
|
||||
_detect_runtime_mode_elevation, _SUBAGENT_SHELL_SECRET_MARKERS,
|
||||
_detect_mutative_toggle_self_change, _detect_evolution_owner_control_self_change,
|
||||
_detect_context_mode_self_lowering, _DENIED_READ_OPTIONS,
|
||||
_is_pure_read_inspection, _detect_safety_mode_self_lowering,
|
||||
_detect_owner_skill_attest_self_call. All re-emitted from tip bytes,
|
||||
transplant proof green (ast=tokens=bytes on every symbol, exit 0).
|
||||
2. Rows whose reference destination carries the TYPED-RESULT cutover semantics
|
||||
(PURE V7 DELTA; tip==merge-base): 144 (_normalize_dispatch_path_args reduced
|
||||
to a projection), 184 (_binding_error_text native codes), 185
|
||||
(_payload_dispatch_constraint typed second element), 226
|
||||
(_managed_update_code_tool_block thin wrapper), 138 (ToolEntry shallow-frozen
|
||||
— also upstream-drifted: tip added the alias_for field). This lane moved the
|
||||
TIP bodies verbatim; the typed deltas are deliberately NOT ported — they ride
|
||||
with the F2 typed-result organ, not with a byte-preserving relocation of a
|
||||
protected file.
|
||||
3. HOT-DEFERRED: ouroboros/tools/registry_core.py (rows 156, 167, 170, 171,
|
||||
174, 175). Evidence: tip ToolRegistry is a 2252-line class (probe: tip span
|
||||
124364B vs reference 49860B, ast_equal=False); the reference slimmed it via
|
||||
17 method->function extractions (rows 189, 224, 225, 230, 235-242, 287,
|
||||
291-293) which change the receiver (self -> registry) and are NOT
|
||||
byte-preserving relocation — out of bounds for the protected
|
||||
tools/registry.py under this lane's mandate. ToolRegistry and the four
|
||||
process/mutation constants stay in the facade; the class also would put the
|
||||
new leaf straight into the >1500 band. Re-split from the upstream form in F2.
|
||||
4. HOT-DEFERRED: ouroboros/tools/tool_result.py. 32 of the reference leaf's 33
|
||||
top-level symbols do not exist at tip (the ToolResult/ToolCodeSpec organ,
|
||||
D02-family approved deltas); the single registry-sourced verbatim row 139
|
||||
(_compose_execute_result) also drifted at tip (661B vs 671B). Creating a
|
||||
one-symbol leaf under the organ's name would falsely anchor the F2 re-split;
|
||||
_compose_execute_result stays in the facade.
|
||||
5. HOT-DEFERRED: rows 187/188 (ToolRegistry._dispatch_mcp_tool /
|
||||
_dispatch_extension_tool -> extension_dispatch typed dispatchers).
|
||||
tip tools/extension_dispatch.py == merge-base (116 lines); the reference's
|
||||
+177 lines are the producer-boundary ToolResult typing plus method
|
||||
retirement. Upstream bytes stand; the methods stay on the class.
|
||||
6. loop_tool_execution.py D04 rows (157, 159-164, 826-828) are ALL
|
||||
retire/rename/type rows of the classifier cutover — nothing is emittable as
|
||||
a byte-preserving span. Shared-monolith convention honored: this lane did
|
||||
not touch ouroboros/loop_tool_execution.py at all (D01 owns the rest).
|
||||
7. tools/core.py shared-leaf note (row 353, core.py::active_repo_dir_for ->
|
||||
tool_resolution.py): already satisfied at tip by an import alias
|
||||
(core.py:20 imports it from the registry; the registry facade now re-exports
|
||||
it from tool_resolution — same object). core.py untouched by this lane.
|
||||
8. tool_access split rows 495-535 RE-PROVEN against tip bytes: 39/41 spans
|
||||
byte-identical; 2 BYTE-FALSIFIED as copy sources by PURE UPSTREAM DRIFT:
|
||||
_skill_payload_base (upstream re-homed the body into
|
||||
skill_payload_binding.resolve_skill_payload_base — copying the reference
|
||||
leaf would have reverted that refactor) and ResolvedResourceBinding
|
||||
(upstream added the logical_base_path field). Both re-emitted from tip
|
||||
bytes, proof green. The D1 mirror-path defect (safe_relpath lstrip('/'),
|
||||
lying "caller rejects" docstrings) travels in the moved tip bytes UNFIXED,
|
||||
per the lane instruction — it remains an upstream issue-candidate.
|
||||
9. Pins carried with disclosed adaptations (identity continuations to tip
|
||||
bytes): tests/test_tool_owner_facades.py (+ the alias_for row in the
|
||||
ToolEntry contract — upstream drift); tests/test_tool_access_extraction.py
|
||||
(4 adaptations, listed in its docstring: tool_module_inventory clause
|
||||
dropped until that leaf lands, backedge check narrowed to import-time
|
||||
imports because the D18/D33 call-time handle is deliberate, one-matrix
|
||||
clause asserts through the facade re-export, size bounds kept);
|
||||
tests/test_workspace_authority_binding.py gains the reference's
|
||||
tool_resolution identity test while its typed companion
|
||||
(_normalize_dispatch_path_args_result) is NOT carried — it pins deferred
|
||||
machinery. test_registry_core.py, test_tool_result*.py and the
|
||||
classification-differential suites are NOT carried for the same reason.
|
||||
10. Test-split rows 784-825 (tests/test_tool_capabilities.py -> 4 siblings)
|
||||
RE-PROVEN against tip bytes: 34/42 moved spans byte-identical to the
|
||||
reference siblings, 8 re-emitted from tip (test_search_code_has_result_limit,
|
||||
test_local_readonly_subagent_execute_blocks_forbidden_tools,
|
||||
test_local_readonly_subagent_initial_schemas_are_allowlisted,
|
||||
test_schedule_subagent_in_initial_schemas,
|
||||
test_schedule_subagent_inherits_workspace_executor_ref, and the three
|
||||
test_schedule_subagent_required_*_for_readonly tests). Lossless: 61 == 61
|
||||
test functions, zero lost, zero added, no duplicate names introduced
|
||||
(tree-wide AST dup scan; the 10 pre-existing identical-body duplicates
|
||||
between test_review_cycles_dispatch.py and test_review_cycles_skill_dispatch.py
|
||||
plus the test_tool_registered same-name pair predate this lane — D06/D05
|
||||
territory, reported not touched). 21 unrowed/kept tip tests remain in the
|
||||
remainder; 3 header imports that lost their last reader were dropped there.
|
||||
11. Protection-surface closure (code-side, protective-only): the reference
|
||||
extends ouroboros/runtime_mode_policy.py::SAFETY_CRITICAL_PATHS and
|
||||
supervisor/update_merge_policy.py::HOT_CODE_PATHS over the registry split
|
||||
leaves — without that, guard bodies moved out of the protected registry
|
||||
become writable in advanced mode and lose the hot-code label (this tree's
|
||||
own parity rule, tests/test_lc2_owner_facades.py, pins the inverse
|
||||
direction). This lane mirrored the closure for the five leaves that exist
|
||||
here (registry_core.py / tool_result.py rows return with their leaves) and
|
||||
pinned it (tests/test_tool_owner_facades.py::
|
||||
test_registry_split_leaves_keep_protected_label_parity). NOT mirrored —
|
||||
for the owner/F5: the reference's prose updates to prompts/SAFETY.md:10
|
||||
and prompts/SYSTEM.md "Immutable Safety Files" (operator-off-limits
|
||||
runtime prompts; enforcement is code-side, prose enumerates only the
|
||||
facade for now), and the reference's extra HOT_CODE_PATHS row for
|
||||
ouroboros/tools/extension_dispatch.py (nothing moved there on this tree —
|
||||
adding it is an oracle delta beyond relocation parity).
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ SAFETY_CRITICAL_PATHS = frozenset({
|
|||
"ouroboros/runtime_mode_policy.py",
|
||||
"ouroboros/tools/extension_dispatch.py",
|
||||
"ouroboros/tools/registry.py",
|
||||
# The v7 D04 split moved guard/resolution bodies out of the protected
|
||||
# registry without moving any of the risk, so every inventory that
|
||||
# protects the parent must cover the leaves (label parity — same rule as
|
||||
# the git_ops family).
|
||||
"ouroboros/tools/registry_guard_process.py",
|
||||
"ouroboros/tools/registry_guards.py",
|
||||
"ouroboros/tools/tool_catalog.py",
|
||||
"ouroboros/tools/tool_context.py",
|
||||
"ouroboros/tools/tool_resolution.py",
|
||||
"prompts/SAFETY.md",
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ GIANT_PATHS = (
|
|||
"tests/test_skill_loader.py",
|
||||
"tests/test_skill_review.py",
|
||||
"tests/test_task_status_flow.py",
|
||||
"tests/test_tool_capabilities.py",
|
||||
"tests/test_ui_smoke_playwright.py",
|
||||
"web/modules/chat.js",
|
||||
"web/tests/harness_accounts.test.js",
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ migrated to neutral tool names.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import os # noqa: F401 — historical facade surface
|
||||
import pathlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Literal, Optional
|
||||
import re # noqa: F401 — historical facade surface
|
||||
from dataclasses import dataclass # noqa: F401 — historical facade surface
|
||||
from typing import Any, Iterable, Literal, Optional # noqa: F401 — historical facade surface
|
||||
|
||||
from ouroboros.artifacts import (delegated_capture_read_target,
|
||||
task_artifact_dir_path, task_id_for_artifacts)
|
||||
from ouroboros.tool_capabilities import ACTING_SUBAGENT_MODE, LOCAL_READONLY_SUBAGENT_MODE
|
||||
from ouroboros.contracts.task_constraint import VALID_WRITE_SURFACES, normalize_task_constraint
|
||||
from ouroboros.tool_capabilities import ACTING_SUBAGENT_MODE, LOCAL_READONLY_SUBAGENT_MODE # noqa: F401 — historical facade surface
|
||||
from ouroboros.contracts.task_constraint import VALID_WRITE_SURFACES, normalize_task_constraint # noqa: F401 — historical facade surface
|
||||
from ouroboros import deliverables_paths as _deliverables_paths
|
||||
from ouroboros.shell_parse import is_absolute_path_text
|
||||
from ouroboros.utils import safe_relpath
|
||||
|
|
@ -26,345 +26,72 @@ _deliverables_root_lexical = _deliverables_paths._deliverables_root_lexical
|
|||
_deliverables_root_lexical_alias = _deliverables_paths._deliverables_root_lexical_alias
|
||||
_lexical_path_is_relative_to_casefold = _deliverables_paths._lexical_path_is_relative_to_casefold
|
||||
|
||||
# v7 D04 split: the owners below were extracted VERBATIM from this module
|
||||
# (see each leaf's header); re-exported here so historical imports and
|
||||
# monkeypatch targets keep working unchanged.
|
||||
from ouroboros.tool_access_types import ( # noqa: F401 — re-exported moved surface
|
||||
Operation,
|
||||
ResolvedResourceBinding,
|
||||
ResourceRoot,
|
||||
SUBAGENT_CAPABILITIES,
|
||||
SubagentCapability,
|
||||
ToolAccessDecision,
|
||||
ToolProfile,
|
||||
_ALL_ROOTS,
|
||||
_POLICY,
|
||||
_READONLY_RESOURCE_ROOTS,
|
||||
_READ_OPS,
|
||||
_SUBAGENT_CAPABILITY_TO_OPERATION,
|
||||
_TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
_TOP_LEVEL_PRINCIPAL_PROFILES,
|
||||
)
|
||||
from ouroboros.tool_access_paths import ( # noqa: F401 — re-exported moved surface
|
||||
_deliverables_root,
|
||||
_path_is_relative_to_casefold,
|
||||
_user_files_root,
|
||||
canonical_data_root,
|
||||
normalize_root,
|
||||
normalize_root_relative,
|
||||
normalize_runtime_data_path,
|
||||
path_is_relative_to,
|
||||
paths_overlap_casefold,
|
||||
workspace_mode_block_reason,
|
||||
)
|
||||
from ouroboros.tool_access_roots import ( # noqa: F401 — re-exported moved surface
|
||||
_is_subagent_ctx,
|
||||
_skill_payload_base,
|
||||
active_tool_profile,
|
||||
binding_targets_system_repo,
|
||||
is_external_workspace,
|
||||
load_bound_skill,
|
||||
predicted_subagent_profile,
|
||||
project_room_lens_dir,
|
||||
resource_root_path,
|
||||
)
|
||||
from ouroboros.tool_access_user_files import ( # noqa: F401 — re-exported moved surface
|
||||
UserFilesPathBlockedError,
|
||||
_USER_FILES_ALLOWED_DOTNAMES,
|
||||
_USER_FILES_SECRET_COMPONENTS,
|
||||
_USER_FILES_SECRET_NAMES,
|
||||
_USER_FILES_SECRET_RE,
|
||||
_subagent_projects_read_hint,
|
||||
resolve_user_file_path,
|
||||
user_files_path_block_reason,
|
||||
)
|
||||
|
||||
def _user_files_root() -> pathlib.Path:
|
||||
"""Filesystem base for the ``user_files`` resource root.
|
||||
|
||||
Defaults to the owner's real home. A jailed/benchmark runtime can redirect it
|
||||
to a scratch directory via ``OUROBOROS_USER_FILES_ROOT`` so a task physically
|
||||
cannot resolve the owner's real home (e.g. ``~/file1.txt`` secret files). Any
|
||||
unusable value falls back to the real home — fail-safe, never broadens reach.
|
||||
"""
|
||||
raw = (os.environ.get("OUROBOROS_USER_FILES_ROOT") or "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return pathlib.Path(raw).expanduser().resolve(strict=False)
|
||||
except Exception:
|
||||
# ANY unusable value (bad path, unknown ``~user`` RuntimeError, odd OS error)
|
||||
# fails safe to the real home — the doc's "any unusable value" contract.
|
||||
pass
|
||||
return pathlib.Path.home().resolve(strict=False)
|
||||
|
||||
|
||||
def _deliverables_root() -> pathlib.Path:
|
||||
"""Container for UNNAMED user deliverables, JAIL-AWARE: when the user_files home is
|
||||
redirected (``OUROBOROS_USER_FILES_ROOT``) and no explicit
|
||||
``OUROBOROS_DELIVERABLES_ROOT`` is set, keep unnamed deliverables INSIDE the jail so a
|
||||
bare ``write_file(root='user_files', path='answer.txt')`` stays reachable and in-bounds
|
||||
instead of escaping to the real ``~/Ouroboros/Deliverables`` (which the outside-home
|
||||
check would then reject). Otherwise the global config default applies.
|
||||
"""
|
||||
from ouroboros.config import get_deliverables_root
|
||||
|
||||
jail = (os.environ.get("OUROBOROS_USER_FILES_ROOT") or "").strip()
|
||||
explicit = (os.environ.get("OUROBOROS_DELIVERABLES_ROOT") or "").strip()
|
||||
if explicit:
|
||||
return pathlib.Path(explicit).expanduser().resolve(strict=False)
|
||||
if jail and not explicit:
|
||||
return (_user_files_root() / "Deliverables").resolve(strict=False)
|
||||
return pathlib.Path(get_deliverables_root()).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
ToolProfile = Literal[
|
||||
"self_modification",
|
||||
"workspace_task",
|
||||
"external_workspace_task",
|
||||
"acting_subagent",
|
||||
"skill_repair",
|
||||
"local_readonly_subagent",
|
||||
"operator_control",
|
||||
]
|
||||
ResourceRoot = Literal[
|
||||
"active_workspace",
|
||||
"system_repo",
|
||||
"runtime_data",
|
||||
"task_drive",
|
||||
"skill_payload",
|
||||
"artifact_store",
|
||||
"user_files",
|
||||
"subagent_projects",
|
||||
"deliverables",
|
||||
]
|
||||
Operation = Literal[
|
||||
"read",
|
||||
"list",
|
||||
"search",
|
||||
"write",
|
||||
"edit",
|
||||
"shell",
|
||||
"vcs",
|
||||
"review",
|
||||
"delegate",
|
||||
"service",
|
||||
]
|
||||
SubagentCapability = Literal[
|
||||
"write",
|
||||
"edit",
|
||||
"shell",
|
||||
"vcs",
|
||||
"review",
|
||||
"delegate",
|
||||
"service",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolAccessDecision:
|
||||
allow: bool
|
||||
reason: str = ""
|
||||
guard: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedResourceBinding:
|
||||
"""One dispatch-selected logical root and its exact physical target."""
|
||||
|
||||
profile: ToolProfile
|
||||
root: ResourceRoot
|
||||
operation: Operation
|
||||
base_path: pathlib.Path
|
||||
target_path: pathlib.Path
|
||||
source: str
|
||||
skill_name: str
|
||||
state_drive_root: pathlib.Path
|
||||
logical_base_path: pathlib.Path | None = None
|
||||
|
||||
|
||||
_ALL_ROOTS: frozenset[str] = frozenset({
|
||||
"active_workspace",
|
||||
"system_repo",
|
||||
"runtime_data",
|
||||
"task_drive",
|
||||
"skill_payload",
|
||||
"artifact_store",
|
||||
"user_files",
|
||||
"subagent_projects",
|
||||
"deliverables",
|
||||
})
|
||||
|
||||
# Deferral 1: orchestrator-visible READ-ONLY roots — durable subagent (genesis) projects
|
||||
# and the unnamed-deliverables container. Only ever granted {read,list,search}; NEVER
|
||||
# write/edit/shell/vcs (no mutation, no shell-cwd — deliberately absent from
|
||||
# resolve_shell_cwd candidates) and NEVER to acting/readonly subagents (a child must not
|
||||
# read sibling projects). operator_control is capped to read-only on these too.
|
||||
_READONLY_RESOURCE_ROOTS: frozenset[str] = frozenset({"subagent_projects", "deliverables"})
|
||||
_TOP_LEVEL_PRINCIPAL_PROFILES: frozenset[str] = frozenset({
|
||||
"workspace_task",
|
||||
"external_workspace_task",
|
||||
"self_modification",
|
||||
})
|
||||
|
||||
_READ_OPS = frozenset({"read", "list", "search"})
|
||||
_USER_FILES_SECRET_COMPONENTS = frozenset({
|
||||
".aws",
|
||||
".azure",
|
||||
".config",
|
||||
".docker",
|
||||
".git", # v6.52.0: VCS internals hold config + stored credentials
|
||||
".gnupg",
|
||||
".hg",
|
||||
".kube",
|
||||
".local",
|
||||
".netrc",
|
||||
".ssh",
|
||||
".svn",
|
||||
"library",
|
||||
})
|
||||
_USER_FILES_SECRET_NAMES = frozenset({
|
||||
".env",
|
||||
# v6.52.0: credential / shell-init / history dotFILES kept blocked AFTER the bare
|
||||
# `startswith('.')` block was dropped (so benign project dotdirs are readable while
|
||||
# secret-bearing dotfiles are not).
|
||||
".bash_history",
|
||||
".bash_profile",
|
||||
".bashrc",
|
||||
".dockercfg",
|
||||
".git-credentials",
|
||||
".gitconfig",
|
||||
".htpasswd",
|
||||
".npmrc",
|
||||
".pgpass",
|
||||
".profile",
|
||||
".pypirc",
|
||||
".python_history",
|
||||
".zsh_history",
|
||||
".zprofile",
|
||||
".zshrc",
|
||||
"auth.json",
|
||||
"credentials",
|
||||
"credentials.json",
|
||||
"secrets.json",
|
||||
"settings.json",
|
||||
"token.json",
|
||||
"tokens.json",
|
||||
})
|
||||
_USER_FILES_SECRET_RE = re.compile(r"(?:^|[._-])(api[_-]?key|credential|password|secret|token)(?:[._-]|$)", re.I)
|
||||
# v6.52.0 (P1): a SMALL allowlist of benign hidden (dot) project components. The dotfile guard
|
||||
# is DEFAULT-DENY: a credential blocklist can never be exhaustive (e.g. ~/.terraform.d,
|
||||
# ~/.cargo/credentials.toml, ~/.oci/config, ~/.pip/pip.conf, ~/.m2/settings.xml, ~/.*_history all
|
||||
# leak under enumeration), so a dotted component is blocked UNLESS it is one of these known-safe
|
||||
# project-config dirs/files. This serves the goal (read .github/.vscode/.idea project config)
|
||||
# without opening the whole in-home dotfile space.
|
||||
_USER_FILES_ALLOWED_DOTNAMES = frozenset({
|
||||
".github",
|
||||
".gitlab",
|
||||
".circleci",
|
||||
".devcontainer",
|
||||
".vscode",
|
||||
".idea",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".gitmodules",
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
})
|
||||
|
||||
_TOP_LEVEL_PRINCIPAL_POLICY: dict[str, set[str]] = {
|
||||
"active_workspace": {"read", "list", "search", "write", "edit", "shell", "vcs", "review", "service"},
|
||||
"system_repo": {"read", "list", "search", "write", "edit", "shell", "vcs", "review", "service"},
|
||||
"runtime_data": {"read", "list", "search", "write", "edit"},
|
||||
"task_drive": {"read", "list", "write", "edit", "shell", "service"},
|
||||
"skill_payload": {"read", "list", "search", "write", "edit", "review", "shell"},
|
||||
"artifact_store": {"read", "list", "write", "shell", "service"},
|
||||
"user_files": {"read", "list", "search", "write", "edit", "shell", "service"},
|
||||
"subagent_projects": {"read", "list", "search"},
|
||||
"deliverables": {"read", "list", "search"},
|
||||
}
|
||||
|
||||
|
||||
_POLICY: dict[str, dict[str, set[str]]] = {
|
||||
"local_readonly_subagent": {
|
||||
# Read-only child VCS names still need their target binding to resolve.
|
||||
"active_workspace": set(_READ_OPS) | {"vcs"},
|
||||
"system_repo": set(_READ_OPS) | {"vcs"},
|
||||
"runtime_data": {"read", "list"},
|
||||
"task_drive": {"read", "list"},
|
||||
"artifact_store": {"read", "list"},
|
||||
# v6.70.0 (owner-approved): read-only scouts sent to review a skill were
|
||||
# structurally blind to its payload — a scout literally reported
|
||||
# "reviewing blind", and a correct "skill does not exist" answer was
|
||||
# indistinguishable from an access block. Payloads are skill CODE
|
||||
# (data/skills/...); grants/secrets live in data/state/skills, which
|
||||
# stays invisible to this profile.
|
||||
"skill_payload": {"read", "list", "search"},
|
||||
},
|
||||
"skill_repair": {
|
||||
"skill_payload": {"read", "list", "search", "write", "edit", "review"},
|
||||
"runtime_data": {"read", "list"},
|
||||
"task_drive": {"read", "list"},
|
||||
"artifact_store": {"read", "list"},
|
||||
},
|
||||
# Top-level preset names remain observable, but workspace focus never narrows
|
||||
# the ordinary principal. Independent path/credential/child/runtime guards
|
||||
# still apply after this shared operation matrix.
|
||||
"workspace_task": _TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
"external_workspace_task": _TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
# Mutative (acting) subagents write only inside their isolated active
|
||||
# workspace (self_worktree / external_workspace / genesis). No vcs-commit /
|
||||
# review here; the parent integrates and commits. self_worktree additionally
|
||||
# keeps protected-path discipline active in the registry (it is the system
|
||||
# repo). runtime_data stays read-only.
|
||||
"acting_subagent": {
|
||||
# Acting children write ONLY inside their isolated surface (active_workspace =
|
||||
# the self_worktree / external_workspace / genesis). task_drive / artifact_store
|
||||
# are read-only here (no extra write surface); the deliverable is a workspace.patch.
|
||||
"active_workspace": {"read", "list", "search", "write", "edit", "shell", "vcs", "service"},
|
||||
"runtime_data": {"read", "list"},
|
||||
"task_drive": {"read", "list"},
|
||||
"artifact_store": {"read", "list"},
|
||||
},
|
||||
"self_modification": _TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
# operator_control gets full authority on every mutable root, but the orchestrator
|
||||
# read-only roots stay read-only even here (they are deliverables/durable projects,
|
||||
# not a control surface).
|
||||
"operator_control": {
|
||||
**{root: {"read", "list", "search", "write", "edit", "shell", "vcs", "review", "delegate", "service"}
|
||||
for root in _ALL_ROOTS if root not in _READONLY_RESOURCE_ROOTS},
|
||||
**{root: {"read", "list", "search"} for root in _READONLY_RESOURCE_ROOTS},
|
||||
},
|
||||
}
|
||||
_SUBAGENT_CAPABILITY_TO_OPERATION: dict[str, Operation] = {
|
||||
"write": "write",
|
||||
"edit": "edit",
|
||||
"shell": "shell",
|
||||
"vcs": "vcs",
|
||||
"review": "review",
|
||||
"delegate": "delegate",
|
||||
"service": "service",
|
||||
}
|
||||
SUBAGENT_CAPABILITIES: tuple[str, ...] = tuple(_SUBAGENT_CAPABILITY_TO_OPERATION.keys())
|
||||
|
||||
|
||||
def _is_subagent_ctx(ctx: Any) -> bool:
|
||||
"""True when the task is a delegated subagent (by lineage metadata)."""
|
||||
for attr in ("task_metadata", "task_contract"):
|
||||
data = getattr(ctx, attr, None)
|
||||
if isinstance(data, dict) and str(data.get("delegation_role") or "").strip() == "subagent":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_external_workspace(ctx: Any) -> bool:
|
||||
"""True for an EXTERNAL-workspace top-level task (not the system repo).
|
||||
|
||||
External-workspace tasks operate on a pre-existing working tree somewhere on
|
||||
the host (container scratch, a repo cloned under ``/tmp`` or ``/build``,
|
||||
etc.). They legitimately read, run commands, and use git OUTSIDE the user
|
||||
home, while the Ouroboros runtime (system repo + data drive) and
|
||||
credential-like files stay protected by the per-path guards. ``self_worktree``
|
||||
and ``genesis`` are acting-subagent SURFACES (``acting_subagent`` profile),
|
||||
never this profile, so they keep full home/runtime confinement.
|
||||
"""
|
||||
try:
|
||||
if not bool(getattr(ctx, "is_workspace_mode", lambda: False)()):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return str(getattr(ctx, "workspace_mode", "") or "").strip().lower() == "external"
|
||||
|
||||
|
||||
def active_tool_profile(ctx: Any) -> ToolProfile:
|
||||
constraint = normalize_task_constraint(getattr(ctx, "task_constraint", None))
|
||||
mode = str(getattr(constraint, "mode", "") or "").strip()
|
||||
if mode == LOCAL_READONLY_SUBAGENT_MODE:
|
||||
return "local_readonly_subagent"
|
||||
if mode == ACTING_SUBAGENT_MODE:
|
||||
# Acting subagents require a resolved write surface; otherwise fail
|
||||
# closed to read-only rather than inheriting a broader profile.
|
||||
surface = str(getattr(constraint, "surface", "") or "").strip()
|
||||
if surface in VALID_WRITE_SURFACES:
|
||||
return "acting_subagent"
|
||||
return "local_readonly_subagent"
|
||||
if mode == "skill_repair":
|
||||
return "skill_repair"
|
||||
# Fail-closed floor (BIBLE P3), checked BEFORE workspace/direct-chat: a
|
||||
# delegated subagent without a valid readonly/acting/skill constraint is
|
||||
# read-only and must never inherit workspace_task / operator_control /
|
||||
# self_modification. The parent remains the sole local writer/committer.
|
||||
if _is_subagent_ctx(ctx):
|
||||
return "local_readonly_subagent"
|
||||
if bool(getattr(ctx, "is_workspace_mode", lambda: False)()):
|
||||
# Keep distinct preset names for focus/path diagnostics. Both use the
|
||||
# shared ordinary principal; external host-scratch reach is a path fact.
|
||||
if is_external_workspace(ctx):
|
||||
return "external_workspace_task"
|
||||
return "workspace_task"
|
||||
if bool(getattr(ctx, "is_direct_chat", False)):
|
||||
return "operator_control"
|
||||
return "self_modification"
|
||||
|
||||
|
||||
def predicted_subagent_profile(*, write_surface: str = "") -> ToolProfile:
|
||||
"""The tool profile a scheduled subagent will resolve to, from schedule-time
|
||||
inputs only (v6.57.0, 1.6). A valid write_surface → acting_subagent; otherwise
|
||||
a read-only subagent. Mirrors active_tool_profile's subagent branches so the
|
||||
parent's schedule result and the child's start context can preview the profile
|
||||
without a live ctx. NOT authoritative — the supervisor's _resolve_subagent_
|
||||
constraint is the real gate; this is a visibility preview."""
|
||||
surface = str(write_surface or "").strip()
|
||||
if surface and surface in VALID_WRITE_SURFACES:
|
||||
return "acting_subagent"
|
||||
return "local_readonly_subagent"
|
||||
|
||||
|
||||
def summarize_subagent_profile(profile: ToolProfile, *, effective_lane: str = "") -> str:
|
||||
|
|
@ -516,27 +243,6 @@ def _side_effect_free_process_roots(
|
|||
]
|
||||
|
||||
|
||||
def project_room_lens_dir(ctx: Any) -> Optional[pathlib.Path]:
|
||||
"""Return a direct-chat room's verified project cwd, otherwise ``None``.
|
||||
|
||||
Promoted/workspace/subagent tasks carry their own workspace; only a direct
|
||||
chat without one may use the injected existing ``_project_room_dir``.
|
||||
"""
|
||||
if not bool(getattr(ctx, "is_direct_chat", False)):
|
||||
return None
|
||||
if getattr(ctx, "workspace_root", None):
|
||||
return None
|
||||
meta = getattr(ctx, "task_metadata", None)
|
||||
raw = str(meta.get("_project_room_dir") or "").strip() if isinstance(meta, dict) else ""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
candidate = pathlib.Path(raw).resolve(strict=False)
|
||||
return candidate if candidate.is_dir() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def filesystem_affordance_map(ctx: Any, *, runtime_mode: str = "") -> dict[str, Any]:
|
||||
"""A compact, side-effect-free projection of filesystem/tool access affordances.
|
||||
|
||||
|
|
@ -662,86 +368,6 @@ def shell_cwd_block_message(ctx: Any, cwd: str = "", *, operation: Operation = "
|
|||
)
|
||||
|
||||
|
||||
def normalize_root(root: str | None, *, default: ResourceRoot = "active_workspace") -> ResourceRoot:
|
||||
candidate = str(root or default).strip() or default
|
||||
if candidate not in _ALL_ROOTS:
|
||||
raise ValueError(f"unknown root {candidate!r}; expected one of {sorted(_ALL_ROOTS)}")
|
||||
return candidate # type: ignore[return-value]
|
||||
|
||||
|
||||
def path_is_relative_to(path: pathlib.Path, root: pathlib.Path) -> bool:
|
||||
try:
|
||||
pathlib.Path(path).resolve(strict=False).relative_to(pathlib.Path(root).resolve(strict=False))
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def normalize_root_relative(root: pathlib.Path, path: str) -> str:
|
||||
"""Map a model-supplied path to a root-relative string when it redundantly
|
||||
encodes the root, so structural/read tools accept the paths an agent
|
||||
naturally writes: an absolute path inside the active root (e.g. ``/app/foo``
|
||||
under a workspace rooted at ``/app``) and a single redundant root-basename
|
||||
prefix (``app/foo``). Returns a RELATIVE string only — it never widens
|
||||
access: callers still apply ``safe_relpath`` + a ``relative_to`` confinement
|
||||
check, so a genuine escape is still rejected downstream.
|
||||
|
||||
- absolute & inside root -> stripped to relative
|
||||
- absolute & outside root -> returned unchanged (caller's check rejects it)
|
||||
- redundant root-basename prefix, existence-guarded -> stripped
|
||||
- otherwise -> unchanged
|
||||
"""
|
||||
|
||||
text = str(path or "").strip().replace("\\", "/")
|
||||
if not text or text in (".", "./"):
|
||||
return text
|
||||
try:
|
||||
root_resolved = pathlib.Path(root).resolve(strict=False)
|
||||
except (OSError, ValueError):
|
||||
return text
|
||||
# (A) absolute path that already points inside the root.
|
||||
if is_absolute_path_text(text):
|
||||
try:
|
||||
return pathlib.Path(text).resolve(strict=False).relative_to(root_resolved).as_posix()
|
||||
except (OSError, ValueError):
|
||||
return text # outside root -> let the caller's confinement reject it
|
||||
# (B) redundant root-basename prefix ('app' or 'app/x' when root basename is
|
||||
# 'app'). Strip it UNLESS the root contains a real same-named subdir (then
|
||||
# 'app/x' is ambiguously a genuine nested path and is kept). Gating on the
|
||||
# absence of that subdir — not on the target existing — lets NEW write/create
|
||||
# targets ('app/new.py' -> 'new.py') normalize too, while a real 'app/'
|
||||
# subdir is never mis-stripped. Only ever shortens toward root (no escape).
|
||||
base = root_resolved.name
|
||||
if base and (text == base or text.startswith(base + "/")):
|
||||
try:
|
||||
if not (root_resolved / base).is_dir():
|
||||
return text[len(base):].lstrip("/") or "."
|
||||
except (ValueError, OSError):
|
||||
# `..`/traversal or stat error: leave unchanged so the caller's
|
||||
# confinement produces the canonical (not a generic) error.
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def _path_is_relative_to_casefold(path: pathlib.Path, root: pathlib.Path) -> bool:
|
||||
try:
|
||||
path_parts = pathlib.Path(path).resolve(strict=False).parts
|
||||
root_parts = pathlib.Path(root).resolve(strict=False).parts
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if len(path_parts) < len(root_parts):
|
||||
return False
|
||||
return tuple(part.casefold() for part in path_parts[: len(root_parts)]) == tuple(
|
||||
part.casefold() for part in root_parts
|
||||
)
|
||||
|
||||
|
||||
def paths_overlap_casefold(left: pathlib.Path, right: pathlib.Path) -> bool:
|
||||
"""Return True when two paths overlap under case-insensitive path semantics."""
|
||||
|
||||
return _path_is_relative_to_casefold(left, right) or _path_is_relative_to_casefold(right, left)
|
||||
|
||||
|
||||
def light_cognitive_or_root_redirect(tool_name: str, args: dict[str, Any]) -> str | None:
|
||||
"""Precise light-mode redirect for write attempts that should use a cognitive
|
||||
tool or an explicit ``user_files`` root. Returns the message, or ``None``.
|
||||
|
|
@ -804,313 +430,6 @@ def light_cognitive_or_root_redirect(tool_name: str, args: dict[str, Any]) -> st
|
|||
return None
|
||||
|
||||
|
||||
def workspace_mode_block_reason(ctx: Any) -> str:
|
||||
mode = str(getattr(ctx, "workspace_mode", "") or "").strip()
|
||||
workspace_root = getattr(ctx, "workspace_root", None)
|
||||
if not mode or workspace_root is None:
|
||||
return ""
|
||||
try:
|
||||
workspace = pathlib.Path(workspace_root).resolve(strict=False)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return "workspace_root is invalid"
|
||||
protected_values = (
|
||||
("Ouroboros system repo", getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir", None)),
|
||||
("Ouroboros repo", getattr(ctx, "repo_dir", None)),
|
||||
("Ouroboros data drive", getattr(ctx, "drive_root", None)),
|
||||
(
|
||||
"Ouroboros parent data drive",
|
||||
(getattr(ctx, "task_metadata", {}) or {}).get("budget_drive_root")
|
||||
if isinstance(getattr(ctx, "task_metadata", {}), dict)
|
||||
else "",
|
||||
),
|
||||
)
|
||||
for label, value in protected_values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
protected = pathlib.Path(value).resolve(strict=False)
|
||||
except (OSError, TypeError, ValueError):
|
||||
continue
|
||||
if (
|
||||
path_is_relative_to(workspace, protected)
|
||||
or path_is_relative_to(protected, workspace)
|
||||
or paths_overlap_casefold(workspace, protected)
|
||||
):
|
||||
return f"workspace_root overlaps the {label}"
|
||||
return ""
|
||||
|
||||
|
||||
def _subagent_projects_read_hint(
|
||||
ctx: Any,
|
||||
resolved: pathlib.Path,
|
||||
hard_protected_roots: list[pathlib.Path],
|
||||
) -> str:
|
||||
"""A targeted refusal for a user_files path that actually lives inside the
|
||||
subagent-projects area: name root=subagent_projects with the exact relative
|
||||
path instead of steering the model at roots that cannot reach the target.
|
||||
Empty when the target is not there, the active profile cannot read that root,
|
||||
or the projects root is misconfigured to overlap a HARD drive (never steer a
|
||||
read at the control plane)."""
|
||||
try:
|
||||
profile_policy = _POLICY.get(active_tool_profile(ctx), {})
|
||||
if "read" not in profile_policy.get("subagent_projects", set()):
|
||||
return ""
|
||||
projects_root = resource_root_path(ctx, "subagent_projects")
|
||||
if any(
|
||||
path_is_relative_to(projects_root, hard) or _path_is_relative_to_casefold(projects_root, hard)
|
||||
for hard in hard_protected_roots
|
||||
):
|
||||
return ""
|
||||
if not (
|
||||
path_is_relative_to(resolved, projects_root)
|
||||
or _path_is_relative_to_casefold(resolved, projects_root)
|
||||
):
|
||||
return ""
|
||||
try:
|
||||
rel = str(resolved.relative_to(projects_root))
|
||||
except ValueError:
|
||||
rel = os.path.relpath(str(resolved), str(projects_root))
|
||||
rel = rel if rel not in ("", ".") else "."
|
||||
return (
|
||||
"this path is inside root=subagent_projects (the durable child-project "
|
||||
f"area); read it via root=subagent_projects, path={rel!r} "
|
||||
"(read/list/search only — no write/shell there by design: children "
|
||||
"write via write_surface=external_workspace, and the host "
|
||||
"checkpoint-commits dirty coop trees at root finalization)"
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def user_files_path_block_reason(
|
||||
ctx: Any,
|
||||
candidate: pathlib.Path,
|
||||
*,
|
||||
allow_protected_descendants: bool = False,
|
||||
) -> str:
|
||||
"""Return a block reason when candidate is not an external user file."""
|
||||
|
||||
resolved = pathlib.Path(candidate).expanduser().resolve(strict=False)
|
||||
home = _user_files_root()
|
||||
outside_home = not path_is_relative_to(resolved, home) and not _path_is_relative_to_casefold(resolved, home)
|
||||
# External-workspace tasks may reach host scratch outside home (/tmp, /build,
|
||||
# sibling checkouts). The runtime-overlap and credential guards BELOW still
|
||||
# run on the full path, so the Ouroboros repo/data drive and secret-like
|
||||
# files stay protected even when home confinement is lifted.
|
||||
if outside_home and not is_external_workspace(ctx):
|
||||
return f"path is outside user home {home}"
|
||||
|
||||
# The Ouroboros runtime/control surface is the system repo PLUS every data
|
||||
# drive the task touches: the parent drive (ctx.drive_root) and any child /
|
||||
# budget drive carried in task_metadata. External-workspace mode lifts home
|
||||
# confinement, so these must be enumerated explicitly here — otherwise a
|
||||
# child-drive control path (e.g. <child_drive>/memory) would slip through.
|
||||
protected_values: list[Any] = [
|
||||
getattr(ctx, "drive_root", None),
|
||||
getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir", None),
|
||||
]
|
||||
meta = getattr(ctx, "task_metadata", {})
|
||||
if isinstance(meta, dict):
|
||||
for key in ("drive_root", "child_drive_root", "headless_child_drive_root", "budget_drive_root"):
|
||||
if meta.get(key):
|
||||
protected_values.append(meta.get(key))
|
||||
protected_roots: list[pathlib.Path] = []
|
||||
hard_protected_roots: list[pathlib.Path] = [] # the data/repo/budget drives THEMSELVES
|
||||
for value in protected_values:
|
||||
try:
|
||||
root = pathlib.Path(value).resolve(strict=False)
|
||||
except (OSError, TypeError, ValueError):
|
||||
continue
|
||||
protected_roots.append(root)
|
||||
hard_protected_roots.append(root)
|
||||
parent = root.parent.resolve(strict=False)
|
||||
if root.name in {"repo", "data"} and path_is_relative_to(parent, home):
|
||||
# The workspace PARENT is a SOFT boundary (keeps user_files out of ~/Ouroboros at large);
|
||||
# it is deliberately NOT a hard root, so the Deliverables sibling under it stays allowed.
|
||||
protected_roots.append(parent)
|
||||
# The configured Deliverables container is an INTENDED user-output root, allowed past the
|
||||
# workspace-overlap guard — but ONLY when it is a genuine sibling: a misconfigured
|
||||
# OUROBOROS_DELIVERABLES_ROOT that overlaps or contains a HARD data/repo/budget drive must NOT
|
||||
# open a bypass. The outside-home, credential, and hidden-name checks still apply regardless.
|
||||
in_deliverables = False
|
||||
try:
|
||||
_deliverables = _deliverables_root()
|
||||
_deliverables_safe = not any(
|
||||
path_is_relative_to(_deliverables, pr) or _path_is_relative_to_casefold(_deliverables, pr)
|
||||
or path_is_relative_to(pr, _deliverables) or _path_is_relative_to_casefold(pr, _deliverables)
|
||||
for pr in hard_protected_roots
|
||||
)
|
||||
if _deliverables_safe and (
|
||||
path_is_relative_to(resolved, _deliverables) or _path_is_relative_to_casefold(resolved, _deliverables)
|
||||
):
|
||||
in_deliverables = True
|
||||
except Exception:
|
||||
in_deliverables = False
|
||||
if not in_deliverables:
|
||||
for protected in protected_roots:
|
||||
overlaps_protected = path_is_relative_to(resolved, protected) or _path_is_relative_to_casefold(resolved, protected)
|
||||
contains_protected = path_is_relative_to(protected, resolved) or _path_is_relative_to_casefold(protected, resolved)
|
||||
if overlaps_protected or (
|
||||
not allow_protected_descendants and contains_protected
|
||||
):
|
||||
# Name the root that ACTUALLY contains the target (the v6.54.3
|
||||
# shell_cwd_block_message lesson applied to this surface): the
|
||||
# subagent-projects area lives under the SOFT ~/Ouroboros parent,
|
||||
# so every coop-tree read used to get a message naming four roots
|
||||
# that cannot reach it while omitting the one that can. MESSAGE
|
||||
# ONLY — subagent_projects stays a read-only root (no user_files
|
||||
# write carve-out), and a target inside a HARD drive never takes
|
||||
# this branch.
|
||||
projects_hint = _subagent_projects_read_hint(ctx, resolved, hard_protected_roots)
|
||||
if projects_hint:
|
||||
return projects_hint
|
||||
return (
|
||||
"path overlaps the Ouroboros repo/runtime workspace; use "
|
||||
"root=active_workspace, root=task_drive, root=artifact_store, "
|
||||
"or root=skill_payload instead"
|
||||
)
|
||||
|
||||
try:
|
||||
parts = resolved.relative_to(home).parts
|
||||
except ValueError:
|
||||
parts = resolved.parts
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
part_lower = part.lower()
|
||||
# v6.52.0 (P1): DEFAULT-DENY hidden (dot) components. Known secret/credential/VCS dirs
|
||||
# are always blocked; ANY OTHER dotted component is blocked too UNLESS it is in the small
|
||||
# benign allowlist (.github/.vscode/.idea/...). Benign project dotdirs become readable
|
||||
# (the owner's goal) while the in-home dotfile space stays safe-by-default — an enumerated
|
||||
# blocklist would leak credential stores like ~/.terraform.d, ~/.cargo, ~/.pip, etc.
|
||||
if part_lower in _USER_FILES_SECRET_COMPONENTS:
|
||||
return "path is hidden or credential-like (secret/credential directory)"
|
||||
if part.startswith(".") and part_lower not in _USER_FILES_ALLOWED_DOTNAMES:
|
||||
return "path is hidden or credential-like (non-allowlisted hidden component)"
|
||||
name = resolved.name
|
||||
name_lower = name.lower()
|
||||
if (
|
||||
name_lower in _USER_FILES_SECRET_NAMES
|
||||
or _USER_FILES_SECRET_RE.search(name)
|
||||
or name_lower.endswith((".key", ".pem", ".p12", ".pfx"))
|
||||
):
|
||||
return "path name is credential-like"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
class UserFilesPathBlockedError(ValueError):
|
||||
"""Typed user_files confinement refusal (a POLICY denial, not an I/O failure).
|
||||
|
||||
Subclasses ``ValueError`` so every existing generic handler keeps working;
|
||||
the read-surface wrappers (read_file/list_files/search_code) render it with
|
||||
the typed ``⚠️ USER_FILES_PATH_BLOCKED`` prefix so the outcome axis can
|
||||
partition it into ``execution.policy_denials`` (v6.57.0) instead of the
|
||||
generic ``error`` status that falsely degraded a shipped task to
|
||||
``tool_failure`` (the submarine wave-3 incident)."""
|
||||
|
||||
|
||||
def resolve_user_file_path(
|
||||
ctx: Any,
|
||||
path: str,
|
||||
*,
|
||||
allow_protected_descendants: bool = False,
|
||||
allow_outside_home: bool = False,
|
||||
) -> pathlib.Path:
|
||||
"""Resolve a user_files path under the user's home and outside Ouroboros control-plane roots.
|
||||
|
||||
Absolute paths OUTSIDE the user_files home (and the Deliverables container) are
|
||||
rejected EARLY with an actionable error instead of resolving to a foreign root
|
||||
and failing later with an opaque ``relative_to`` crash (v6.54.3 — the TB2.1
|
||||
``'/app' is not in the subpath of '/root'`` class). ``allow_outside_home=True``
|
||||
(the ``query_code`` external-target caller) skips only this EARLY actionable
|
||||
check; ``user_files_path_block_reason`` below remains the outside-home
|
||||
AUTHORITY, and it permits outside-home only for external-workspace contexts —
|
||||
the mode the documented query_code contract (benchmark ``/app``) runs in.
|
||||
Neither flag expands authority: a non-external context could not reach
|
||||
outside-home before this check existed either."""
|
||||
|
||||
raw_text = str(path or ".").strip() or "."
|
||||
try:
|
||||
raw = pathlib.Path(raw_text).expanduser()
|
||||
except Exception:
|
||||
# expanduser() raises RuntimeError for an unknown '~user'; leave it unexpanded —
|
||||
# the '~' branch below maps it into the jail home (raw is only used elsewhere for
|
||||
# absolute paths, where expanduser is a no-op anyway).
|
||||
raw = pathlib.Path(raw_text)
|
||||
home = _user_files_root()
|
||||
# is_absolute_path_text gives consistent cross-platform absolute detection
|
||||
# (drive-less "/x" roots and "C:\\x"/"\\\\unc" are all absolute) so Windows
|
||||
# does not silently treat a rooted path as home-relative.
|
||||
if is_absolute_path_text(raw_text):
|
||||
candidate = raw.resolve(strict=False)
|
||||
# External-workspace tasks legitimately reach host scratch outside home
|
||||
# (/tmp, /build, sibling checkouts) — for them the generic
|
||||
# user_files_path_block_reason below stays the authority, mirroring its
|
||||
# own is_external_workspace carve-out.
|
||||
if not allow_outside_home and not is_external_workspace(ctx):
|
||||
home_resolved = home.resolve(strict=False)
|
||||
# Case-insensitive-platform parity with the user_files_path_block_reason
|
||||
# authority: a differently-cased safe home path must not be rejected
|
||||
# early where the casefold-aware guard would accept it (review round 7).
|
||||
inside_home = path_is_relative_to(candidate, home_resolved) or _path_is_relative_to_casefold(
|
||||
candidate, home_resolved
|
||||
)
|
||||
inside_deliverables = False
|
||||
if not inside_home:
|
||||
try:
|
||||
deliverables_resolved = _deliverables_root().resolve(strict=False)
|
||||
inside_deliverables = path_is_relative_to(
|
||||
candidate, deliverables_resolved
|
||||
) or _path_is_relative_to_casefold(candidate, deliverables_resolved)
|
||||
except (OSError, ValueError):
|
||||
inside_deliverables = False
|
||||
if not inside_home and not inside_deliverables:
|
||||
raise UserFilesPathBlockedError(
|
||||
"user_files path blocked: absolute path "
|
||||
f"{raw_text!r} is outside the user_files home ({home_resolved}). "
|
||||
"Use root='active_workspace' for workspace paths, or a "
|
||||
"home-relative path (e.g. 'Desktop/file.txt') for user files."
|
||||
)
|
||||
elif raw_text.startswith("~"):
|
||||
# '~' / '~user' must expand to the CONFIGURED user_files home (the jail), NOT the
|
||||
# real OS home — otherwise OUROBOROS_USER_FILES_ROOT isolation is bypassed by a
|
||||
# '~/...' path. The jail has a single home, so '~user/sub' maps to '<home>/sub'.
|
||||
_after = raw_text[1:]
|
||||
if _after[:1] in ("/", "\\"):
|
||||
_rel = _after[1:]
|
||||
elif "/" in _after or "\\" in _after:
|
||||
_rel = _after.replace("\\", "/").split("/", 1)[1]
|
||||
else:
|
||||
_rel = "" # bare '~' or '~user' -> the home directory itself
|
||||
candidate = (home / safe_relpath(_rel)).resolve(strict=False) if _rel else home.resolve(strict=False)
|
||||
else:
|
||||
# safe_relpath has already normalized any Windows backslash to a POSIX '/', so the
|
||||
# directory test below is separator-correct on every platform.
|
||||
rel = safe_relpath(raw_text)
|
||||
home_candidate = home / rel
|
||||
if "/" in rel.strip("/") or home_candidate.exists():
|
||||
# An explicit placement (a path WITH a directory — Desktop/..., Downloads/..., a subdir)
|
||||
# OR a bare name that ALREADY EXISTS under home (an existing file or directory such as
|
||||
# `Desktop`) is honored under the owner home exactly as given. This keeps read/list/search
|
||||
# of existing user files and directory names home-relative — only a genuinely NEW unnamed
|
||||
# output is containerized.
|
||||
candidate = home_candidate.resolve(strict=False)
|
||||
else:
|
||||
# A bare name with no directory that does NOT already exist under home is an unnamed NEW
|
||||
# deliverable: route it into the visible Deliverables container instead of cluttering the
|
||||
# home root (a later read of the same bare name resolves there too, staying consistent).
|
||||
candidate = (_deliverables_root() / rel).resolve(strict=False)
|
||||
reason = user_files_path_block_reason(
|
||||
ctx,
|
||||
candidate,
|
||||
allow_protected_descendants=allow_protected_descendants,
|
||||
)
|
||||
if reason:
|
||||
raise UserFilesPathBlockedError(f"user_files path blocked: {reason}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _select_process_target(
|
||||
ctx: Any,
|
||||
cwd: str,
|
||||
|
|
@ -1220,45 +539,6 @@ def resolve_shell_cwd(
|
|||
return target, root, allowed
|
||||
|
||||
|
||||
def canonical_data_root(ctx: Any) -> pathlib.Path:
|
||||
"""Return canonical skill data: task budget → context budget → task drive."""
|
||||
metadata = getattr(ctx, "task_metadata", None)
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
for candidate in (metadata.get("budget_drive_root"), getattr(ctx, "budget_drive_root", "")):
|
||||
text = str(candidate or "").strip()
|
||||
if text:
|
||||
return pathlib.Path(text).resolve(strict=False)
|
||||
return pathlib.Path(getattr(ctx, "drive_root")).resolve(strict=False)
|
||||
|
||||
|
||||
def normalize_runtime_data_path(data_root: pathlib.Path, path: str) -> str:
|
||||
"""Normalize historical runtime-data prefixes before physical binding."""
|
||||
norm = str(path or ".").strip().replace("\\", "/")
|
||||
norm = norm[2:] if norm.startswith("./") else norm
|
||||
stripped = norm.lstrip("/")
|
||||
root_text = str(pathlib.Path(data_root)).rstrip("/").lstrip("/")
|
||||
if root_text and stripped.startswith(root_text):
|
||||
return stripped[len(root_text):].lstrip("/") or "."
|
||||
if stripped.startswith(".tmp-data-"):
|
||||
_prefix, separator, after = stripped.partition("/")
|
||||
if separator:
|
||||
return after[len("data/"):] if after.startswith("data/") else after
|
||||
return norm or "."
|
||||
|
||||
|
||||
def load_bound_skill(binding: ResolvedResourceBinding) -> Any:
|
||||
"""Load the frozen payload target while preserving lifecycle provenance."""
|
||||
from ouroboros.skill_loader import _classify_skill_source, load_skill
|
||||
loaded = load_skill(binding.base_path, binding.state_drive_root)
|
||||
if loaded is not None:
|
||||
loaded.source = _classify_skill_source(
|
||||
binding.base_path,
|
||||
location=binding.source,
|
||||
drive_root=binding.state_drive_root,
|
||||
)
|
||||
return loaded
|
||||
|
||||
|
||||
def canonical_repo_relative_path(ctx: Any, root: str, path: str) -> str:
|
||||
"""Normalize repo paths so guards and mutations judge the same target."""
|
||||
if root not in {"active_workspace", "system_repo"}:
|
||||
|
|
@ -1273,95 +553,6 @@ def canonical_repo_relative_path(ctx: Any, root: str, path: str) -> str:
|
|||
return path
|
||||
|
||||
|
||||
def _skill_payload_base(
|
||||
ctx: Any,
|
||||
*,
|
||||
profile: ToolProfile,
|
||||
operation: Operation,
|
||||
location: str,
|
||||
skill_name: str,
|
||||
allow_missing: bool = False,
|
||||
) -> tuple[pathlib.Path, str, str]:
|
||||
"""Select one physical package and project its effective source."""
|
||||
from ouroboros.skill_payload_binding import resolve_skill_payload_base
|
||||
|
||||
return resolve_skill_payload_base(
|
||||
ctx,
|
||||
drive_root=canonical_data_root(ctx),
|
||||
profile=profile,
|
||||
top_level=profile in _TOP_LEVEL_PRINCIPAL_PROFILES,
|
||||
operation=operation,
|
||||
location=location,
|
||||
skill_name=skill_name,
|
||||
allow_missing=allow_missing,
|
||||
)
|
||||
|
||||
|
||||
def resource_root_path(
|
||||
ctx: Any,
|
||||
root: ResourceRoot,
|
||||
*,
|
||||
bucket: str = "",
|
||||
skill_name: str = "",
|
||||
) -> pathlib.Path:
|
||||
if root == "active_workspace":
|
||||
active = getattr(ctx, "active_repo_dir", None)
|
||||
candidate = None
|
||||
if callable(active):
|
||||
try:
|
||||
candidate = active()
|
||||
except Exception:
|
||||
candidate = None
|
||||
if candidate is None or candidate.__class__.__module__.startswith("unittest.mock"):
|
||||
candidate = getattr(ctx, "repo_dir")
|
||||
return pathlib.Path(candidate).resolve(strict=False)
|
||||
if root == "system_repo":
|
||||
return pathlib.Path(getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir")).resolve(strict=False)
|
||||
if root == "runtime_data":
|
||||
return pathlib.Path(getattr(ctx, "drive_root")).resolve(strict=False)
|
||||
if root == "task_drive":
|
||||
return (pathlib.Path(getattr(ctx, "drive_root")).resolve(strict=False) / "task_drives" / task_id_for_artifacts(ctx)).resolve(strict=False)
|
||||
if root == "artifact_store":
|
||||
return task_artifact_dir_path(pathlib.Path(getattr(ctx, "drive_root")), task_id_for_artifacts(ctx), create=False).resolve(strict=False)
|
||||
if root == "user_files":
|
||||
return _user_files_root()
|
||||
if root == "subagent_projects":
|
||||
from ouroboros.config import get_subagent_projects_root
|
||||
|
||||
return pathlib.Path(get_subagent_projects_root()).expanduser().resolve(strict=False)
|
||||
if root == "deliverables":
|
||||
return _deliverables_root()
|
||||
if root == "skill_payload":
|
||||
b = str(bucket or "").strip()
|
||||
s = str(skill_name or "").strip()
|
||||
if not b or not s:
|
||||
raise ValueError("root=skill_payload requires bucket and skill_name")
|
||||
base, _source, _name = _skill_payload_base(
|
||||
ctx,
|
||||
profile=active_tool_profile(ctx),
|
||||
operation="read",
|
||||
location=b,
|
||||
skill_name=s,
|
||||
)
|
||||
return base
|
||||
raise ValueError(f"unknown root {root!r}")
|
||||
|
||||
|
||||
def binding_targets_system_repo(
|
||||
ctx: Any, binding: ResolvedResourceBinding | None,
|
||||
) -> bool:
|
||||
"""Whether a selected logical root physically lands on Ouroboros source."""
|
||||
|
||||
if binding is None:
|
||||
return False
|
||||
try:
|
||||
return pathlib.Path(binding.base_path).resolve(strict=False) == resource_root_path(
|
||||
ctx, "system_repo",
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_target_in_selected_base(
|
||||
ctx: Any,
|
||||
*,
|
||||
|
|
|
|||
213
ouroboros/tool_access_paths.py
Normal file
213
ouroboros/tool_access_paths.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Physical path primitives for the access matrix.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from ouroboros.tool_access_types import _ALL_ROOTS
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from ouroboros.tool_access_types import ResourceRoot
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _tool_access():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros import tool_access
|
||||
|
||||
return tool_access
|
||||
|
||||
|
||||
def _user_files_root() -> pathlib.Path:
|
||||
"""Filesystem base for the ``user_files`` resource root.
|
||||
|
||||
Defaults to the owner's real home. A jailed/benchmark runtime can redirect it
|
||||
to a scratch directory via ``OUROBOROS_USER_FILES_ROOT`` so a task physically
|
||||
cannot resolve the owner's real home (e.g. ``~/file1.txt`` secret files). Any
|
||||
unusable value falls back to the real home — fail-safe, never broadens reach.
|
||||
"""
|
||||
raw = (os.environ.get("OUROBOROS_USER_FILES_ROOT") or "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return pathlib.Path(raw).expanduser().resolve(strict=False)
|
||||
except Exception:
|
||||
# ANY unusable value (bad path, unknown ``~user`` RuntimeError, odd OS error)
|
||||
# fails safe to the real home — the doc's "any unusable value" contract.
|
||||
pass
|
||||
return pathlib.Path.home().resolve(strict=False)
|
||||
|
||||
|
||||
def _deliverables_root() -> pathlib.Path:
|
||||
"""Container for UNNAMED user deliverables, JAIL-AWARE: when the user_files home is
|
||||
redirected (``OUROBOROS_USER_FILES_ROOT``) and no explicit
|
||||
``OUROBOROS_DELIVERABLES_ROOT`` is set, keep unnamed deliverables INSIDE the jail so a
|
||||
bare ``write_file(root='user_files', path='answer.txt')`` stays reachable and in-bounds
|
||||
instead of escaping to the real ``~/Ouroboros/Deliverables`` (which the outside-home
|
||||
check would then reject). Otherwise the global config default applies.
|
||||
"""
|
||||
from ouroboros.config import get_deliverables_root
|
||||
|
||||
jail = (os.environ.get("OUROBOROS_USER_FILES_ROOT") or "").strip()
|
||||
explicit = (os.environ.get("OUROBOROS_DELIVERABLES_ROOT") or "").strip()
|
||||
if explicit:
|
||||
return pathlib.Path(explicit).expanduser().resolve(strict=False)
|
||||
if jail and not explicit:
|
||||
return (_user_files_root() / "Deliverables").resolve(strict=False)
|
||||
return pathlib.Path(get_deliverables_root()).expanduser().resolve(strict=False)
|
||||
|
||||
|
||||
def normalize_root(root: str | None, *, default: ResourceRoot = "active_workspace") -> ResourceRoot:
|
||||
candidate = str(root or default).strip() or default
|
||||
if candidate not in _ALL_ROOTS:
|
||||
raise ValueError(f"unknown root {candidate!r}; expected one of {sorted(_ALL_ROOTS)}")
|
||||
return candidate # type: ignore[return-value]
|
||||
|
||||
|
||||
def path_is_relative_to(path: pathlib.Path, root: pathlib.Path) -> bool:
|
||||
try:
|
||||
pathlib.Path(path).resolve(strict=False).relative_to(pathlib.Path(root).resolve(strict=False))
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def normalize_root_relative(root: pathlib.Path, path: str) -> str:
|
||||
"""Map a model-supplied path to a root-relative string when it redundantly
|
||||
encodes the root, so structural/read tools accept the paths an agent
|
||||
naturally writes: an absolute path inside the active root (e.g. ``/app/foo``
|
||||
under a workspace rooted at ``/app``) and a single redundant root-basename
|
||||
prefix (``app/foo``). Returns a RELATIVE string only — it never widens
|
||||
access: callers still apply ``safe_relpath`` + a ``relative_to`` confinement
|
||||
check, so a genuine escape is still rejected downstream.
|
||||
|
||||
- absolute & inside root -> stripped to relative
|
||||
- absolute & outside root -> returned unchanged (caller's check rejects it)
|
||||
- redundant root-basename prefix, existence-guarded -> stripped
|
||||
- otherwise -> unchanged
|
||||
"""
|
||||
|
||||
text = str(path or "").strip().replace("\\", "/")
|
||||
if not text or text in (".", "./"):
|
||||
return text
|
||||
try:
|
||||
root_resolved = pathlib.Path(root).resolve(strict=False)
|
||||
except (OSError, ValueError):
|
||||
return text
|
||||
# (A) absolute path that already points inside the root.
|
||||
if _tool_access().is_absolute_path_text(text):
|
||||
try:
|
||||
return pathlib.Path(text).resolve(strict=False).relative_to(root_resolved).as_posix()
|
||||
except (OSError, ValueError):
|
||||
return text # outside root -> let the caller's confinement reject it
|
||||
# (B) redundant root-basename prefix ('app' or 'app/x' when root basename is
|
||||
# 'app'). Strip it UNLESS the root contains a real same-named subdir (then
|
||||
# 'app/x' is ambiguously a genuine nested path and is kept). Gating on the
|
||||
# absence of that subdir — not on the target existing — lets NEW write/create
|
||||
# targets ('app/new.py' -> 'new.py') normalize too, while a real 'app/'
|
||||
# subdir is never mis-stripped. Only ever shortens toward root (no escape).
|
||||
base = root_resolved.name
|
||||
if base and (text == base or text.startswith(base + "/")):
|
||||
try:
|
||||
if not (root_resolved / base).is_dir():
|
||||
return text[len(base):].lstrip("/") or "."
|
||||
except (ValueError, OSError):
|
||||
# `..`/traversal or stat error: leave unchanged so the caller's
|
||||
# confinement produces the canonical (not a generic) error.
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def _path_is_relative_to_casefold(path: pathlib.Path, root: pathlib.Path) -> bool:
|
||||
try:
|
||||
path_parts = pathlib.Path(path).resolve(strict=False).parts
|
||||
root_parts = pathlib.Path(root).resolve(strict=False).parts
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if len(path_parts) < len(root_parts):
|
||||
return False
|
||||
return tuple(part.casefold() for part in path_parts[: len(root_parts)]) == tuple(
|
||||
part.casefold() for part in root_parts
|
||||
)
|
||||
|
||||
|
||||
def paths_overlap_casefold(left: pathlib.Path, right: pathlib.Path) -> bool:
|
||||
"""Return True when two paths overlap under case-insensitive path semantics."""
|
||||
|
||||
return _path_is_relative_to_casefold(left, right) or _path_is_relative_to_casefold(right, left)
|
||||
|
||||
|
||||
def workspace_mode_block_reason(ctx: Any) -> str:
|
||||
mode = str(getattr(ctx, "workspace_mode", "") or "").strip()
|
||||
workspace_root = getattr(ctx, "workspace_root", None)
|
||||
if not mode or workspace_root is None:
|
||||
return ""
|
||||
try:
|
||||
workspace = pathlib.Path(workspace_root).resolve(strict=False)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return "workspace_root is invalid"
|
||||
protected_values = (
|
||||
("Ouroboros system repo", getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir", None)),
|
||||
("Ouroboros repo", getattr(ctx, "repo_dir", None)),
|
||||
("Ouroboros data drive", getattr(ctx, "drive_root", None)),
|
||||
(
|
||||
"Ouroboros parent data drive",
|
||||
(getattr(ctx, "task_metadata", {}) or {}).get("budget_drive_root")
|
||||
if isinstance(getattr(ctx, "task_metadata", {}), dict)
|
||||
else "",
|
||||
),
|
||||
)
|
||||
for label, value in protected_values:
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
protected = pathlib.Path(value).resolve(strict=False)
|
||||
except (OSError, TypeError, ValueError):
|
||||
continue
|
||||
if (
|
||||
path_is_relative_to(workspace, protected)
|
||||
or path_is_relative_to(protected, workspace)
|
||||
or paths_overlap_casefold(workspace, protected)
|
||||
):
|
||||
return f"workspace_root overlaps the {label}"
|
||||
return ""
|
||||
|
||||
|
||||
def canonical_data_root(ctx: Any) -> pathlib.Path:
|
||||
"""Return canonical skill data: task budget → context budget → task drive."""
|
||||
metadata = getattr(ctx, "task_metadata", None)
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
for candidate in (metadata.get("budget_drive_root"), getattr(ctx, "budget_drive_root", "")):
|
||||
text = str(candidate or "").strip()
|
||||
if text:
|
||||
return pathlib.Path(text).resolve(strict=False)
|
||||
return pathlib.Path(getattr(ctx, "drive_root")).resolve(strict=False)
|
||||
|
||||
|
||||
def normalize_runtime_data_path(data_root: pathlib.Path, path: str) -> str:
|
||||
"""Normalize historical runtime-data prefixes before physical binding."""
|
||||
norm = str(path or ".").strip().replace("\\", "/")
|
||||
norm = norm[2:] if norm.startswith("./") else norm
|
||||
stripped = norm.lstrip("/")
|
||||
root_text = str(pathlib.Path(data_root)).rstrip("/").lstrip("/")
|
||||
if root_text and stripped.startswith(root_text):
|
||||
return stripped[len(root_text):].lstrip("/") or "."
|
||||
if stripped.startswith(".tmp-data-"):
|
||||
_prefix, separator, after = stripped.partition("/")
|
||||
if separator:
|
||||
return after[len("data/"):] if after.startswith("data/") else after
|
||||
return norm or "."
|
||||
229
ouroboros/tool_access_roots.py
Normal file
229
ouroboros/tool_access_roots.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Who is acting and where each resource root physically lives.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from ouroboros.tool_access_types import Operation
|
||||
from ouroboros.tool_access_types import ResolvedResourceBinding
|
||||
from ouroboros.tool_access_types import ResourceRoot
|
||||
from ouroboros.tool_access_types import ToolProfile
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _tool_access():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros import tool_access
|
||||
|
||||
return tool_access
|
||||
|
||||
|
||||
def _is_subagent_ctx(ctx: Any) -> bool:
|
||||
"""True when the task is a delegated subagent (by lineage metadata)."""
|
||||
for attr in ("task_metadata", "task_contract"):
|
||||
data = getattr(ctx, attr, None)
|
||||
if isinstance(data, dict) and str(data.get("delegation_role") or "").strip() == "subagent":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_external_workspace(ctx: Any) -> bool:
|
||||
"""True for an EXTERNAL-workspace top-level task (not the system repo).
|
||||
|
||||
External-workspace tasks operate on a pre-existing working tree somewhere on
|
||||
the host (container scratch, a repo cloned under ``/tmp`` or ``/build``,
|
||||
etc.). They legitimately read, run commands, and use git OUTSIDE the user
|
||||
home, while the Ouroboros runtime (system repo + data drive) and
|
||||
credential-like files stay protected by the per-path guards. ``self_worktree``
|
||||
and ``genesis`` are acting-subagent SURFACES (``acting_subagent`` profile),
|
||||
never this profile, so they keep full home/runtime confinement.
|
||||
"""
|
||||
try:
|
||||
if not bool(getattr(ctx, "is_workspace_mode", lambda: False)()):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return str(getattr(ctx, "workspace_mode", "") or "").strip().lower() == "external"
|
||||
|
||||
|
||||
def active_tool_profile(ctx: Any) -> ToolProfile:
|
||||
constraint = _tool_access().normalize_task_constraint(getattr(ctx, "task_constraint", None))
|
||||
mode = str(getattr(constraint, "mode", "") or "").strip()
|
||||
if mode == _tool_access().LOCAL_READONLY_SUBAGENT_MODE:
|
||||
return "local_readonly_subagent"
|
||||
if mode == _tool_access().ACTING_SUBAGENT_MODE:
|
||||
# Acting subagents require a resolved write surface; otherwise fail
|
||||
# closed to read-only rather than inheriting a broader profile.
|
||||
surface = str(getattr(constraint, "surface", "") or "").strip()
|
||||
if surface in _tool_access().VALID_WRITE_SURFACES:
|
||||
return "acting_subagent"
|
||||
return "local_readonly_subagent"
|
||||
if mode == "skill_repair":
|
||||
return "skill_repair"
|
||||
# Fail-closed floor (BIBLE P3), checked BEFORE workspace/direct-chat: a
|
||||
# delegated subagent without a valid readonly/acting/skill constraint is
|
||||
# read-only and must never inherit workspace_task / operator_control /
|
||||
# self_modification. The parent remains the sole local writer/committer.
|
||||
if _is_subagent_ctx(ctx):
|
||||
return "local_readonly_subagent"
|
||||
if bool(getattr(ctx, "is_workspace_mode", lambda: False)()):
|
||||
# Keep distinct preset names for focus/path diagnostics. Both use the
|
||||
# shared ordinary principal; external host-scratch reach is a path fact.
|
||||
if is_external_workspace(ctx):
|
||||
return "external_workspace_task"
|
||||
return "workspace_task"
|
||||
if bool(getattr(ctx, "is_direct_chat", False)):
|
||||
return "operator_control"
|
||||
return "self_modification"
|
||||
|
||||
|
||||
def predicted_subagent_profile(*, write_surface: str = "") -> ToolProfile:
|
||||
"""The tool profile a scheduled subagent will resolve to, from schedule-time
|
||||
inputs only (v6.57.0, 1.6). A valid write_surface → acting_subagent; otherwise
|
||||
a read-only subagent. Mirrors active_tool_profile's subagent branches so the
|
||||
parent's schedule result and the child's start context can preview the profile
|
||||
without a live ctx. NOT authoritative — the supervisor's _resolve_subagent_
|
||||
constraint is the real gate; this is a visibility preview."""
|
||||
surface = str(write_surface or "").strip()
|
||||
if surface and surface in _tool_access().VALID_WRITE_SURFACES:
|
||||
return "acting_subagent"
|
||||
return "local_readonly_subagent"
|
||||
|
||||
|
||||
def project_room_lens_dir(ctx: Any) -> Optional[pathlib.Path]:
|
||||
"""Return a direct-chat room's verified project cwd, otherwise ``None``.
|
||||
|
||||
Promoted/workspace/subagent tasks carry their own workspace; only a direct
|
||||
chat without one may use the injected existing ``_project_room_dir``.
|
||||
"""
|
||||
if not bool(getattr(ctx, "is_direct_chat", False)):
|
||||
return None
|
||||
if getattr(ctx, "workspace_root", None):
|
||||
return None
|
||||
meta = getattr(ctx, "task_metadata", None)
|
||||
raw = str(meta.get("_project_room_dir") or "").strip() if isinstance(meta, dict) else ""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
candidate = pathlib.Path(raw).resolve(strict=False)
|
||||
return candidate if candidate.is_dir() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def load_bound_skill(binding: ResolvedResourceBinding) -> Any:
|
||||
"""Load the frozen payload target while preserving lifecycle provenance."""
|
||||
from ouroboros.skill_loader import _classify_skill_source, load_skill
|
||||
loaded = load_skill(binding.base_path, binding.state_drive_root)
|
||||
if loaded is not None:
|
||||
loaded.source = _classify_skill_source(
|
||||
binding.base_path,
|
||||
location=binding.source,
|
||||
drive_root=binding.state_drive_root,
|
||||
)
|
||||
return loaded
|
||||
|
||||
|
||||
def _skill_payload_base(
|
||||
ctx: Any,
|
||||
*,
|
||||
profile: ToolProfile,
|
||||
operation: Operation,
|
||||
location: str,
|
||||
skill_name: str,
|
||||
allow_missing: bool = False,
|
||||
) -> tuple[pathlib.Path, str, str]:
|
||||
"""Select one physical package and project its effective source."""
|
||||
from ouroboros.skill_payload_binding import resolve_skill_payload_base
|
||||
|
||||
return resolve_skill_payload_base(
|
||||
ctx,
|
||||
drive_root=_tool_access().canonical_data_root(ctx),
|
||||
profile=profile,
|
||||
top_level=profile in _tool_access()._TOP_LEVEL_PRINCIPAL_PROFILES,
|
||||
operation=operation,
|
||||
location=location,
|
||||
skill_name=skill_name,
|
||||
allow_missing=allow_missing,
|
||||
)
|
||||
|
||||
|
||||
def resource_root_path(
|
||||
ctx: Any,
|
||||
root: ResourceRoot,
|
||||
*,
|
||||
bucket: str = "",
|
||||
skill_name: str = "",
|
||||
) -> pathlib.Path:
|
||||
if root == "active_workspace":
|
||||
active = getattr(ctx, "active_repo_dir", None)
|
||||
candidate = None
|
||||
if callable(active):
|
||||
try:
|
||||
candidate = active()
|
||||
except Exception:
|
||||
candidate = None
|
||||
if candidate is None or candidate.__class__.__module__.startswith("unittest.mock"):
|
||||
candidate = getattr(ctx, "repo_dir")
|
||||
return pathlib.Path(candidate).resolve(strict=False)
|
||||
if root == "system_repo":
|
||||
return pathlib.Path(getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir")).resolve(strict=False)
|
||||
if root == "runtime_data":
|
||||
return pathlib.Path(getattr(ctx, "drive_root")).resolve(strict=False)
|
||||
if root == "task_drive":
|
||||
return (pathlib.Path(getattr(ctx, "drive_root")).resolve(strict=False) / "task_drives" / _tool_access().task_id_for_artifacts(ctx)).resolve(strict=False)
|
||||
if root == "artifact_store":
|
||||
return _tool_access().task_artifact_dir_path(pathlib.Path(getattr(ctx, "drive_root")), _tool_access().task_id_for_artifacts(ctx), create=False).resolve(strict=False)
|
||||
if root == "user_files":
|
||||
return _tool_access()._user_files_root()
|
||||
if root == "subagent_projects":
|
||||
from ouroboros.config import get_subagent_projects_root
|
||||
|
||||
return pathlib.Path(get_subagent_projects_root()).expanduser().resolve(strict=False)
|
||||
if root == "deliverables":
|
||||
return _tool_access()._deliverables_root()
|
||||
if root == "skill_payload":
|
||||
b = str(bucket or "").strip()
|
||||
s = str(skill_name or "").strip()
|
||||
if not b or not s:
|
||||
raise ValueError("root=skill_payload requires bucket and skill_name")
|
||||
base, _source, _name = _skill_payload_base(
|
||||
ctx,
|
||||
profile=active_tool_profile(ctx),
|
||||
operation="read",
|
||||
location=b,
|
||||
skill_name=s,
|
||||
)
|
||||
return base
|
||||
raise ValueError(f"unknown root {root!r}")
|
||||
|
||||
|
||||
def binding_targets_system_repo(
|
||||
ctx: Any, binding: ResolvedResourceBinding | None,
|
||||
) -> bool:
|
||||
"""Whether a selected logical root physically lands on Ouroboros source."""
|
||||
|
||||
if binding is None:
|
||||
return False
|
||||
try:
|
||||
return pathlib.Path(binding.base_path).resolve(strict=False) == resource_root_path(
|
||||
ctx, "system_repo",
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
return False
|
||||
207
ouroboros/tool_access_types.py
Normal file
207
ouroboros/tool_access_types.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""The closed access vocabulary and the profile x root x operation policy matrix.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
import pathlib
|
||||
|
||||
|
||||
def _tool_access():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros import tool_access
|
||||
|
||||
return tool_access
|
||||
|
||||
|
||||
ToolProfile = Literal[
|
||||
"self_modification",
|
||||
"workspace_task",
|
||||
"external_workspace_task",
|
||||
"acting_subagent",
|
||||
"skill_repair",
|
||||
"local_readonly_subagent",
|
||||
"operator_control",
|
||||
]
|
||||
|
||||
|
||||
ResourceRoot = Literal[
|
||||
"active_workspace",
|
||||
"system_repo",
|
||||
"runtime_data",
|
||||
"task_drive",
|
||||
"skill_payload",
|
||||
"artifact_store",
|
||||
"user_files",
|
||||
"subagent_projects",
|
||||
"deliverables",
|
||||
]
|
||||
|
||||
|
||||
Operation = Literal[
|
||||
"read",
|
||||
"list",
|
||||
"search",
|
||||
"write",
|
||||
"edit",
|
||||
"shell",
|
||||
"vcs",
|
||||
"review",
|
||||
"delegate",
|
||||
"service",
|
||||
]
|
||||
|
||||
|
||||
SubagentCapability = Literal[
|
||||
"write",
|
||||
"edit",
|
||||
"shell",
|
||||
"vcs",
|
||||
"review",
|
||||
"delegate",
|
||||
"service",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolAccessDecision:
|
||||
allow: bool
|
||||
reason: str = ""
|
||||
guard: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedResourceBinding:
|
||||
"""One dispatch-selected logical root and its exact physical target."""
|
||||
|
||||
profile: ToolProfile
|
||||
root: ResourceRoot
|
||||
operation: Operation
|
||||
base_path: pathlib.Path
|
||||
target_path: pathlib.Path
|
||||
source: str
|
||||
skill_name: str
|
||||
state_drive_root: pathlib.Path
|
||||
logical_base_path: pathlib.Path | None = None
|
||||
|
||||
|
||||
_ALL_ROOTS: frozenset[str] = frozenset({
|
||||
"active_workspace",
|
||||
"system_repo",
|
||||
"runtime_data",
|
||||
"task_drive",
|
||||
"skill_payload",
|
||||
"artifact_store",
|
||||
"user_files",
|
||||
"subagent_projects",
|
||||
"deliverables",
|
||||
})
|
||||
|
||||
|
||||
_READONLY_RESOURCE_ROOTS: frozenset[str] = frozenset({"subagent_projects", "deliverables"})
|
||||
|
||||
|
||||
_TOP_LEVEL_PRINCIPAL_PROFILES: frozenset[str] = frozenset({
|
||||
"workspace_task",
|
||||
"external_workspace_task",
|
||||
"self_modification",
|
||||
})
|
||||
|
||||
|
||||
_READ_OPS = frozenset({"read", "list", "search"})
|
||||
|
||||
|
||||
_TOP_LEVEL_PRINCIPAL_POLICY: dict[str, set[str]] = {
|
||||
"active_workspace": {"read", "list", "search", "write", "edit", "shell", "vcs", "review", "service"},
|
||||
"system_repo": {"read", "list", "search", "write", "edit", "shell", "vcs", "review", "service"},
|
||||
"runtime_data": {"read", "list", "search", "write", "edit"},
|
||||
"task_drive": {"read", "list", "write", "edit", "shell", "service"},
|
||||
"skill_payload": {"read", "list", "search", "write", "edit", "review", "shell"},
|
||||
"artifact_store": {"read", "list", "write", "shell", "service"},
|
||||
"user_files": {"read", "list", "search", "write", "edit", "shell", "service"},
|
||||
"subagent_projects": {"read", "list", "search"},
|
||||
"deliverables": {"read", "list", "search"},
|
||||
}
|
||||
|
||||
|
||||
_POLICY: dict[str, dict[str, set[str]]] = {
|
||||
"local_readonly_subagent": {
|
||||
# Read-only child VCS names still need their target binding to resolve.
|
||||
"active_workspace": set(_READ_OPS) | {"vcs"},
|
||||
"system_repo": set(_READ_OPS) | {"vcs"},
|
||||
"runtime_data": {"read", "list"},
|
||||
"task_drive": {"read", "list"},
|
||||
"artifact_store": {"read", "list"},
|
||||
# v6.70.0 (owner-approved): read-only scouts sent to review a skill were
|
||||
# structurally blind to its payload — a scout literally reported
|
||||
# "reviewing blind", and a correct "skill does not exist" answer was
|
||||
# indistinguishable from an access block. Payloads are skill CODE
|
||||
# (data/skills/...); grants/secrets live in data/state/skills, which
|
||||
# stays invisible to this profile.
|
||||
"skill_payload": {"read", "list", "search"},
|
||||
},
|
||||
"skill_repair": {
|
||||
"skill_payload": {"read", "list", "search", "write", "edit", "review"},
|
||||
"runtime_data": {"read", "list"},
|
||||
"task_drive": {"read", "list"},
|
||||
"artifact_store": {"read", "list"},
|
||||
},
|
||||
# Top-level preset names remain observable, but workspace focus never narrows
|
||||
# the ordinary principal. Independent path/credential/child/runtime guards
|
||||
# still apply after this shared operation matrix.
|
||||
"workspace_task": _TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
"external_workspace_task": _TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
# Mutative (acting) subagents write only inside their isolated active
|
||||
# workspace (self_worktree / external_workspace / genesis). No vcs-commit /
|
||||
# review here; the parent integrates and commits. self_worktree additionally
|
||||
# keeps protected-path discipline active in the registry (it is the system
|
||||
# repo). runtime_data stays read-only.
|
||||
"acting_subagent": {
|
||||
# Acting children write ONLY inside their isolated surface (active_workspace =
|
||||
# the self_worktree / external_workspace / genesis). task_drive / artifact_store
|
||||
# are read-only here (no extra write surface); the deliverable is a workspace.patch.
|
||||
"active_workspace": {"read", "list", "search", "write", "edit", "shell", "vcs", "service"},
|
||||
"runtime_data": {"read", "list"},
|
||||
"task_drive": {"read", "list"},
|
||||
"artifact_store": {"read", "list"},
|
||||
},
|
||||
"self_modification": _TOP_LEVEL_PRINCIPAL_POLICY,
|
||||
# operator_control gets full authority on every mutable root, but the orchestrator
|
||||
# read-only roots stay read-only even here (they are deliverables/durable projects,
|
||||
# not a control surface).
|
||||
"operator_control": {
|
||||
**{root: {"read", "list", "search", "write", "edit", "shell", "vcs", "review", "delegate", "service"}
|
||||
for root in _ALL_ROOTS if root not in _READONLY_RESOURCE_ROOTS},
|
||||
**{root: {"read", "list", "search"} for root in _READONLY_RESOURCE_ROOTS},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_SUBAGENT_CAPABILITY_TO_OPERATION: dict[str, Operation] = {
|
||||
"write": "write",
|
||||
"edit": "edit",
|
||||
"shell": "shell",
|
||||
"vcs": "vcs",
|
||||
"review": "review",
|
||||
"delegate": "delegate",
|
||||
"service": "service",
|
||||
}
|
||||
|
||||
|
||||
SUBAGENT_CAPABILITIES: tuple[str, ...] = tuple(_SUBAGENT_CAPABILITY_TO_OPERATION.keys())
|
||||
367
ouroboros/tool_access_user_files.py
Normal file
367
ouroboros/tool_access_user_files.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"""The user_files confinement: secret-name policy and path resolution.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _tool_access():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros import tool_access
|
||||
|
||||
return tool_access
|
||||
|
||||
|
||||
_USER_FILES_SECRET_COMPONENTS = frozenset({
|
||||
".aws",
|
||||
".azure",
|
||||
".config",
|
||||
".docker",
|
||||
".git", # v6.52.0: VCS internals hold config + stored credentials
|
||||
".gnupg",
|
||||
".hg",
|
||||
".kube",
|
||||
".local",
|
||||
".netrc",
|
||||
".ssh",
|
||||
".svn",
|
||||
"library",
|
||||
})
|
||||
|
||||
|
||||
_USER_FILES_SECRET_NAMES = frozenset({
|
||||
".env",
|
||||
# v6.52.0: credential / shell-init / history dotFILES kept blocked AFTER the bare
|
||||
# `startswith('.')` block was dropped (so benign project dotdirs are readable while
|
||||
# secret-bearing dotfiles are not).
|
||||
".bash_history",
|
||||
".bash_profile",
|
||||
".bashrc",
|
||||
".dockercfg",
|
||||
".git-credentials",
|
||||
".gitconfig",
|
||||
".htpasswd",
|
||||
".npmrc",
|
||||
".pgpass",
|
||||
".profile",
|
||||
".pypirc",
|
||||
".python_history",
|
||||
".zsh_history",
|
||||
".zprofile",
|
||||
".zshrc",
|
||||
"auth.json",
|
||||
"credentials",
|
||||
"credentials.json",
|
||||
"secrets.json",
|
||||
"settings.json",
|
||||
"token.json",
|
||||
"tokens.json",
|
||||
})
|
||||
|
||||
|
||||
_USER_FILES_SECRET_RE = re.compile(r"(?:^|[._-])(api[_-]?key|credential|password|secret|token)(?:[._-]|$)", re.I)
|
||||
|
||||
|
||||
_USER_FILES_ALLOWED_DOTNAMES = frozenset({
|
||||
".github",
|
||||
".gitlab",
|
||||
".circleci",
|
||||
".devcontainer",
|
||||
".vscode",
|
||||
".idea",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".gitmodules",
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
})
|
||||
|
||||
|
||||
def _subagent_projects_read_hint(
|
||||
ctx: Any,
|
||||
resolved: pathlib.Path,
|
||||
hard_protected_roots: list[pathlib.Path],
|
||||
) -> str:
|
||||
"""A targeted refusal for a user_files path that actually lives inside the
|
||||
subagent-projects area: name root=subagent_projects with the exact relative
|
||||
path instead of steering the model at roots that cannot reach the target.
|
||||
Empty when the target is not there, the active profile cannot read that root,
|
||||
or the projects root is misconfigured to overlap a HARD drive (never steer a
|
||||
read at the control plane)."""
|
||||
try:
|
||||
profile_policy = _tool_access()._POLICY.get(_tool_access().active_tool_profile(ctx), {})
|
||||
if "read" not in profile_policy.get("subagent_projects", set()):
|
||||
return ""
|
||||
projects_root = _tool_access().resource_root_path(ctx, "subagent_projects")
|
||||
if any(
|
||||
_tool_access().path_is_relative_to(projects_root, hard) or _tool_access()._path_is_relative_to_casefold(projects_root, hard)
|
||||
for hard in hard_protected_roots
|
||||
):
|
||||
return ""
|
||||
if not (
|
||||
_tool_access().path_is_relative_to(resolved, projects_root)
|
||||
or _tool_access()._path_is_relative_to_casefold(resolved, projects_root)
|
||||
):
|
||||
return ""
|
||||
try:
|
||||
rel = str(resolved.relative_to(projects_root))
|
||||
except ValueError:
|
||||
rel = os.path.relpath(str(resolved), str(projects_root))
|
||||
rel = rel if rel not in ("", ".") else "."
|
||||
return (
|
||||
"this path is inside root=subagent_projects (the durable child-project "
|
||||
f"area); read it via root=subagent_projects, path={rel!r} "
|
||||
"(read/list/search only — no write/shell there by design: children "
|
||||
"write via write_surface=external_workspace, and the host "
|
||||
"checkpoint-commits dirty coop trees at root finalization)"
|
||||
)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def user_files_path_block_reason(
|
||||
ctx: Any,
|
||||
candidate: pathlib.Path,
|
||||
*,
|
||||
allow_protected_descendants: bool = False,
|
||||
) -> str:
|
||||
"""Return a block reason when candidate is not an external user file."""
|
||||
|
||||
resolved = pathlib.Path(candidate).expanduser().resolve(strict=False)
|
||||
home = _tool_access()._user_files_root()
|
||||
outside_home = not _tool_access().path_is_relative_to(resolved, home) and not _tool_access()._path_is_relative_to_casefold(resolved, home)
|
||||
# External-workspace tasks may reach host scratch outside home (/tmp, /build,
|
||||
# sibling checkouts). The runtime-overlap and credential guards BELOW still
|
||||
# run on the full path, so the Ouroboros repo/data drive and secret-like
|
||||
# files stay protected even when home confinement is lifted.
|
||||
if outside_home and not _tool_access().is_external_workspace(ctx):
|
||||
return f"path is outside user home {home}"
|
||||
|
||||
# The Ouroboros runtime/control surface is the system repo PLUS every data
|
||||
# drive the task touches: the parent drive (ctx.drive_root) and any child /
|
||||
# budget drive carried in task_metadata. External-workspace mode lifts home
|
||||
# confinement, so these must be enumerated explicitly here — otherwise a
|
||||
# child-drive control path (e.g. <child_drive>/memory) would slip through.
|
||||
protected_values: list[Any] = [
|
||||
getattr(ctx, "drive_root", None),
|
||||
getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir", None),
|
||||
]
|
||||
meta = getattr(ctx, "task_metadata", {})
|
||||
if isinstance(meta, dict):
|
||||
for key in ("drive_root", "child_drive_root", "headless_child_drive_root", "budget_drive_root"):
|
||||
if meta.get(key):
|
||||
protected_values.append(meta.get(key))
|
||||
protected_roots: list[pathlib.Path] = []
|
||||
hard_protected_roots: list[pathlib.Path] = [] # the data/repo/budget drives THEMSELVES
|
||||
for value in protected_values:
|
||||
try:
|
||||
root = pathlib.Path(value).resolve(strict=False)
|
||||
except (OSError, TypeError, ValueError):
|
||||
continue
|
||||
protected_roots.append(root)
|
||||
hard_protected_roots.append(root)
|
||||
parent = root.parent.resolve(strict=False)
|
||||
if root.name in {"repo", "data"} and _tool_access().path_is_relative_to(parent, home):
|
||||
# The workspace PARENT is a SOFT boundary (keeps user_files out of ~/Ouroboros at large);
|
||||
# it is deliberately NOT a hard root, so the Deliverables sibling under it stays allowed.
|
||||
protected_roots.append(parent)
|
||||
# The configured Deliverables container is an INTENDED user-output root, allowed past the
|
||||
# workspace-overlap guard — but ONLY when it is a genuine sibling: a misconfigured
|
||||
# OUROBOROS_DELIVERABLES_ROOT that overlaps or contains a HARD data/repo/budget drive must NOT
|
||||
# open a bypass. The outside-home, credential, and hidden-name checks still apply regardless.
|
||||
in_deliverables = False
|
||||
try:
|
||||
_deliverables = _tool_access()._deliverables_root()
|
||||
_deliverables_safe = not any(
|
||||
_tool_access().path_is_relative_to(_deliverables, pr) or _tool_access()._path_is_relative_to_casefold(_deliverables, pr)
|
||||
or _tool_access().path_is_relative_to(pr, _deliverables) or _tool_access()._path_is_relative_to_casefold(pr, _deliverables)
|
||||
for pr in hard_protected_roots
|
||||
)
|
||||
if _deliverables_safe and (
|
||||
_tool_access().path_is_relative_to(resolved, _deliverables) or _tool_access()._path_is_relative_to_casefold(resolved, _deliverables)
|
||||
):
|
||||
in_deliverables = True
|
||||
except Exception:
|
||||
in_deliverables = False
|
||||
if not in_deliverables:
|
||||
for protected in protected_roots:
|
||||
overlaps_protected = _tool_access().path_is_relative_to(resolved, protected) or _tool_access()._path_is_relative_to_casefold(resolved, protected)
|
||||
contains_protected = _tool_access().path_is_relative_to(protected, resolved) or _tool_access()._path_is_relative_to_casefold(protected, resolved)
|
||||
if overlaps_protected or (
|
||||
not allow_protected_descendants and contains_protected
|
||||
):
|
||||
# Name the root that ACTUALLY contains the target (the v6.54.3
|
||||
# shell_cwd_block_message lesson applied to this surface): the
|
||||
# subagent-projects area lives under the SOFT ~/Ouroboros parent,
|
||||
# so every coop-tree read used to get a message naming four roots
|
||||
# that cannot reach it while omitting the one that can. MESSAGE
|
||||
# ONLY — subagent_projects stays a read-only root (no user_files
|
||||
# write carve-out), and a target inside a HARD drive never takes
|
||||
# this branch.
|
||||
projects_hint = _subagent_projects_read_hint(ctx, resolved, hard_protected_roots)
|
||||
if projects_hint:
|
||||
return projects_hint
|
||||
return (
|
||||
"path overlaps the Ouroboros repo/runtime workspace; use "
|
||||
"root=active_workspace, root=task_drive, root=artifact_store, "
|
||||
"or root=skill_payload instead"
|
||||
)
|
||||
|
||||
try:
|
||||
parts = resolved.relative_to(home).parts
|
||||
except ValueError:
|
||||
parts = resolved.parts
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
part_lower = part.lower()
|
||||
# v6.52.0 (P1): DEFAULT-DENY hidden (dot) components. Known secret/credential/VCS dirs
|
||||
# are always blocked; ANY OTHER dotted component is blocked too UNLESS it is in the small
|
||||
# benign allowlist (.github/.vscode/.idea/...). Benign project dotdirs become readable
|
||||
# (the owner's goal) while the in-home dotfile space stays safe-by-default — an enumerated
|
||||
# blocklist would leak credential stores like ~/.terraform.d, ~/.cargo, ~/.pip, etc.
|
||||
if part_lower in _USER_FILES_SECRET_COMPONENTS:
|
||||
return "path is hidden or credential-like (secret/credential directory)"
|
||||
if part.startswith(".") and part_lower not in _USER_FILES_ALLOWED_DOTNAMES:
|
||||
return "path is hidden or credential-like (non-allowlisted hidden component)"
|
||||
name = resolved.name
|
||||
name_lower = name.lower()
|
||||
if (
|
||||
name_lower in _USER_FILES_SECRET_NAMES
|
||||
or _USER_FILES_SECRET_RE.search(name)
|
||||
or name_lower.endswith((".key", ".pem", ".p12", ".pfx"))
|
||||
):
|
||||
return "path name is credential-like"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
class UserFilesPathBlockedError(ValueError):
|
||||
"""Typed user_files confinement refusal (a POLICY denial, not an I/O failure).
|
||||
|
||||
Subclasses ``ValueError`` so every existing generic handler keeps working;
|
||||
the read-surface wrappers (read_file/list_files/search_code) render it with
|
||||
the typed ``⚠️ USER_FILES_PATH_BLOCKED`` prefix so the outcome axis can
|
||||
partition it into ``execution.policy_denials`` (v6.57.0) instead of the
|
||||
generic ``error`` status that falsely degraded a shipped task to
|
||||
``tool_failure`` (the submarine wave-3 incident)."""
|
||||
|
||||
|
||||
def resolve_user_file_path(
|
||||
ctx: Any,
|
||||
path: str,
|
||||
*,
|
||||
allow_protected_descendants: bool = False,
|
||||
allow_outside_home: bool = False,
|
||||
) -> pathlib.Path:
|
||||
"""Resolve a user_files path under the user's home and outside Ouroboros control-plane roots.
|
||||
|
||||
Absolute paths OUTSIDE the user_files home (and the Deliverables container) are
|
||||
rejected EARLY with an actionable error instead of resolving to a foreign root
|
||||
and failing later with an opaque ``relative_to`` crash (v6.54.3 — the TB2.1
|
||||
``'/app' is not in the subpath of '/root'`` class). ``allow_outside_home=True``
|
||||
(the ``query_code`` external-target caller) skips only this EARLY actionable
|
||||
check; ``user_files_path_block_reason`` below remains the outside-home
|
||||
AUTHORITY, and it permits outside-home only for external-workspace contexts —
|
||||
the mode the documented query_code contract (benchmark ``/app``) runs in.
|
||||
Neither flag expands authority: a non-external context could not reach
|
||||
outside-home before this check existed either."""
|
||||
|
||||
raw_text = str(path or ".").strip() or "."
|
||||
try:
|
||||
raw = pathlib.Path(raw_text).expanduser()
|
||||
except Exception:
|
||||
# expanduser() raises RuntimeError for an unknown '~user'; leave it unexpanded —
|
||||
# the '~' branch below maps it into the jail home (raw is only used elsewhere for
|
||||
# absolute paths, where expanduser is a no-op anyway).
|
||||
raw = pathlib.Path(raw_text)
|
||||
home = _tool_access()._user_files_root()
|
||||
# is_absolute_path_text gives consistent cross-platform absolute detection
|
||||
# (drive-less "/x" roots and "C:\\x"/"\\\\unc" are all absolute) so Windows
|
||||
# does not silently treat a rooted path as home-relative.
|
||||
if _tool_access().is_absolute_path_text(raw_text):
|
||||
candidate = raw.resolve(strict=False)
|
||||
# External-workspace tasks legitimately reach host scratch outside home
|
||||
# (/tmp, /build, sibling checkouts) — for them the generic
|
||||
# user_files_path_block_reason below stays the authority, mirroring its
|
||||
# own is_external_workspace carve-out.
|
||||
if not allow_outside_home and not _tool_access().is_external_workspace(ctx):
|
||||
home_resolved = home.resolve(strict=False)
|
||||
# Case-insensitive-platform parity with the user_files_path_block_reason
|
||||
# authority: a differently-cased safe home path must not be rejected
|
||||
# early where the casefold-aware guard would accept it (review round 7).
|
||||
inside_home = _tool_access().path_is_relative_to(candidate, home_resolved) or _tool_access()._path_is_relative_to_casefold(
|
||||
candidate, home_resolved
|
||||
)
|
||||
inside_deliverables = False
|
||||
if not inside_home:
|
||||
try:
|
||||
deliverables_resolved = _tool_access()._deliverables_root().resolve(strict=False)
|
||||
inside_deliverables = _tool_access().path_is_relative_to(
|
||||
candidate, deliverables_resolved
|
||||
) or _tool_access()._path_is_relative_to_casefold(candidate, deliverables_resolved)
|
||||
except (OSError, ValueError):
|
||||
inside_deliverables = False
|
||||
if not inside_home and not inside_deliverables:
|
||||
raise UserFilesPathBlockedError(
|
||||
"user_files path blocked: absolute path "
|
||||
f"{raw_text!r} is outside the user_files home ({home_resolved}). "
|
||||
"Use root='active_workspace' for workspace paths, or a "
|
||||
"home-relative path (e.g. 'Desktop/file.txt') for user files."
|
||||
)
|
||||
elif raw_text.startswith("~"):
|
||||
# '~' / '~user' must expand to the CONFIGURED user_files home (the jail), NOT the
|
||||
# real OS home — otherwise OUROBOROS_USER_FILES_ROOT isolation is bypassed by a
|
||||
# '~/...' path. The jail has a single home, so '~user/sub' maps to '<home>/sub'.
|
||||
_after = raw_text[1:]
|
||||
if _after[:1] in ("/", "\\"):
|
||||
_rel = _after[1:]
|
||||
elif "/" in _after or "\\" in _after:
|
||||
_rel = _after.replace("\\", "/").split("/", 1)[1]
|
||||
else:
|
||||
_rel = "" # bare '~' or '~user' -> the home directory itself
|
||||
candidate = (home / _tool_access().safe_relpath(_rel)).resolve(strict=False) if _rel else home.resolve(strict=False)
|
||||
else:
|
||||
# safe_relpath has already normalized any Windows backslash to a POSIX '/', so the
|
||||
# directory test below is separator-correct on every platform.
|
||||
rel = _tool_access().safe_relpath(raw_text)
|
||||
home_candidate = home / rel
|
||||
if "/" in rel.strip("/") or home_candidate.exists():
|
||||
# An explicit placement (a path WITH a directory — Desktop/..., Downloads/..., a subdir)
|
||||
# OR a bare name that ALREADY EXISTS under home (an existing file or directory such as
|
||||
# `Desktop`) is honored under the owner home exactly as given. This keeps read/list/search
|
||||
# of existing user files and directory names home-relative — only a genuinely NEW unnamed
|
||||
# output is containerized.
|
||||
candidate = home_candidate.resolve(strict=False)
|
||||
else:
|
||||
# A bare name with no directory that does NOT already exist under home is an unnamed NEW
|
||||
# deliverable: route it into the visible Deliverables container instead of cluttering the
|
||||
# home root (a later read of the same bare name resolves there too, staying consistent).
|
||||
candidate = (_tool_access()._deliverables_root() / rel).resolve(strict=False)
|
||||
reason = user_files_path_block_reason(
|
||||
ctx,
|
||||
candidate,
|
||||
allow_protected_descendants=allow_protected_descendants,
|
||||
)
|
||||
if reason:
|
||||
raise UserFilesPathBlockedError(f"user_files path blocked: {reason}")
|
||||
return candidate
|
||||
File diff suppressed because it is too large
Load diff
450
ouroboros/tools/registry_guard_process.py
Normal file
450
ouroboros/tools/registry_guard_process.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
"""Process/shell guard helpers: self-change tripwires, read-only inspection classification and light-mode repo snapshots.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
from ouroboros.contracts.skill_payload_policy import SKILL_OWNER_STATE_STEMS
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _registry():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros.tools import registry
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
def _detect_runtime_mode_elevation(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect shell/script attempts to change ``OUROBOROS_RUNTIME_MODE``."""
|
||||
has_save = "save_settings" in text_lower
|
||||
has_mode_key = "ouroboros_runtime_mode" in text_lower
|
||||
has_dotted_path = "ouroboros.config.save_settings" in text_lower
|
||||
detected = (has_save and has_mode_key) or has_dotted_path
|
||||
return _registry()._owner_control_mention_blocks(text_lower, detected, writeish)
|
||||
|
||||
|
||||
_SUBAGENT_SHELL_SECRET_MARKERS = (
|
||||
# Ouroboros owner secrets/control state. The relative form (no leading slash)
|
||||
# closes the interpreter-string bypass (CW4, v6.34.0): the whole-command
|
||||
# substring scan already catches "/data/settings.json" and "../../data/..",
|
||||
# but a bare "data/settings.json" (e.g. python -c "open('data/settings.json')"
|
||||
# from a workspace cwd) needs the slash-less marker too.
|
||||
"/data/settings.json", "data/settings.json", "ouroboros/data/settings", "file1.txt",
|
||||
# Universal credential/secret/control files (relative or absolute).
|
||||
# ouroboros-update-tx.json is the managed-update tx marker (.git/…): owner
|
||||
# control state, mirrored on .git/config. Subagent shell only — the
|
||||
# authorized resolver is the MAIN agent and the supervisor/host writers go
|
||||
# through supervisor.update_merge, so neither is affected (synthesis F3).
|
||||
".env", ".git/config", ".git/credentials", "ouroboros-update-tx.json",
|
||||
"credentials.json", "tokens.json",
|
||||
"/.ssh/", ".ssh/", "id_rsa", "id_ed25519", ".netrc", ".npmrc", ".pgpass", ".aws/",
|
||||
)
|
||||
|
||||
|
||||
def _subagent_shell_targets_secret(cmd_path_lower: str) -> bool:
|
||||
"""Deterministic guard: a shell command referencing Ouroboros secrets/credentials
|
||||
or owner-control state (settings.json, ssh keys, token/credential files)."""
|
||||
return any(marker in cmd_path_lower for marker in _SUBAGENT_SHELL_SECRET_MARKERS)
|
||||
|
||||
|
||||
def _detect_mutative_toggle_self_change(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect shell/script/CLI attempts to change the owner-only mutative-subagents toggle."""
|
||||
has_key = "ouroboros_allow_mutative_subagents" in text_lower
|
||||
has_write = (
|
||||
"save_settings" in text_lower
|
||||
or "settings.json" in text_lower
|
||||
or "/api/settings" in text_lower
|
||||
or "settings set" in text_lower # `ouroboros settings set <key> <value>` CLI path
|
||||
or "ouroboros.cli" in text_lower
|
||||
)
|
||||
return _registry()._owner_control_mention_blocks(text_lower, has_key and has_write, writeish)
|
||||
|
||||
|
||||
def _detect_evolution_owner_control_self_change(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect shell/script/CLI attempts to set the owner-only self-evolution controls:
|
||||
the post-task evolution toggle OR the persistent evolution-objective steer (which
|
||||
biases every evolution campaign, so it is owner-only like the toggle)."""
|
||||
has_key = (
|
||||
"ouroboros_post_task_evolution" in text_lower
|
||||
or "ouroboros_evolution_persistent_objective" in text_lower
|
||||
)
|
||||
has_write = (
|
||||
"save_settings" in text_lower
|
||||
or "settings.json" in text_lower
|
||||
or "/api/settings" in text_lower
|
||||
or "settings set" in text_lower
|
||||
or "ouroboros.cli" in text_lower
|
||||
)
|
||||
return _registry()._owner_control_mention_blocks(text_lower, has_key and has_write, writeish)
|
||||
|
||||
|
||||
def _detect_context_mode_self_lowering(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect shell/script attempts to lower the owner-controlled context mode."""
|
||||
mentions_context_key = "ouroboros_context_mode" in text_lower
|
||||
mentions_owner_endpoint = "/api/owner/context-mode" in text_lower
|
||||
mentions_context_endpoint = "context-mode" in text_lower and "/api/owner" in text_lower
|
||||
mentions_context_cli = "context-mode" in text_lower and (
|
||||
"ouroboros settings" in text_lower
|
||||
or "ouroboros.cli" in text_lower
|
||||
)
|
||||
mentions_save = "save_settings" in text_lower or "settings.json" in text_lower
|
||||
mentions_owner_lowering_flag = "allow_context_lowering" in text_lower
|
||||
detected = (
|
||||
mentions_owner_endpoint
|
||||
or mentions_context_endpoint
|
||||
or mentions_context_cli
|
||||
or mentions_owner_lowering_flag
|
||||
or (mentions_context_key and mentions_save)
|
||||
)
|
||||
return _registry()._owner_control_mention_blocks(text_lower, detected, writeish)
|
||||
|
||||
|
||||
_READ_ONLY_INSPECTION_COMMANDS = frozenset({
|
||||
"grep", "egrep", "fgrep", "zgrep", "rg", "ag", "ack", "ripgrep",
|
||||
"cat", "bat", "head", "tail", "less", "more", "nl", "strings",
|
||||
"ls", "find", "fd", "stat", "file", "wc", "sort", "uniq", "cut", "tr", "column",
|
||||
"basename", "dirname", "realpath", "readlink", "diff", "cmp", "jq", "yq",
|
||||
"echo", "printf", "true", "pwd", "date", "tree",
|
||||
})
|
||||
|
||||
|
||||
_COMMAND_HEAD_WRAPPERS = frozenset({
|
||||
"sudo", "env", "command", "builtin", "exec", "nohup", "time", "nice", "ionice",
|
||||
"stdbuf", "\\",
|
||||
})
|
||||
|
||||
|
||||
_READ_ONLY_GIT_SUBCOMMANDS = frozenset({
|
||||
"grep", "log", "show", "diff", "blame", "cat-file", "ls-files", "ls-tree",
|
||||
"rev-parse", "status", "describe",
|
||||
})
|
||||
|
||||
|
||||
_SEARCH_TOOL_EXEC_OPTIONS = frozenset({"--pre", "--pre-glob", "--hostname-bin", "--pager"})
|
||||
|
||||
|
||||
_DENIED_READ_OPTIONS: dict = {
|
||||
# find/fd run and delete: -exec/-execdir/-ok/-okdir/-x, -delete, and the -f* writers.
|
||||
"find": frozenset({
|
||||
"-exec", "-execdir", "-ok", "-okdir", "-delete",
|
||||
"-fls", "-fprint", "-fprint0", "-fprintf",
|
||||
}),
|
||||
"fd": frozenset({"-x", "--exec", "--exec-batch"}),
|
||||
"rg": _SEARCH_TOOL_EXEC_OPTIONS,
|
||||
"ripgrep": _SEARCH_TOOL_EXEC_OPTIONS,
|
||||
"ag": _SEARCH_TOOL_EXEC_OPTIONS,
|
||||
"ack": _SEARCH_TOOL_EXEC_OPTIONS,
|
||||
# yq edits the named file in place with -i/--inplace; without this the family
|
||||
# read-carve exempted `yq -i '.OUROBOROS_SAFETY_MODE="off"' settings.json` as
|
||||
# "pure inspection" (jq has no in-place edit and stays a stdout-only read).
|
||||
"yq": frozenset({"-i", "--inplace"}),
|
||||
"sort": frozenset({"-o", "--output", "--compress-program"}),
|
||||
"less": frozenset({"-o", "--log-file", "-k", "--lesskey-file"}),
|
||||
"more": frozenset({"-o"}),
|
||||
"file": frozenset({"-c", "--compile"}),
|
||||
# git: external diff/textconv helpers execute a configured program, -o/--output and
|
||||
# git grep -O write or spawn a pager, --exec-path relocates the git binaries.
|
||||
"git": frozenset({
|
||||
"-c", "--config-env", "--exec-path", "--ext-diff", "--textconv",
|
||||
"-o", "--output", "--open-files-in-pager",
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
_TRUSTED_EXECUTABLE_DIRS = frozenset({
|
||||
"/bin", "/usr/bin", "/usr/local/bin", "/sbin", "/usr/sbin", "/opt/homebrew/bin",
|
||||
})
|
||||
|
||||
|
||||
def _trusted_read_head(token: str) -> str:
|
||||
"""The allowlist-comparable command name, or "" when the executable is untrusted."""
|
||||
if "\\" in token:
|
||||
return "" # a windows/escaped path is not a form we can resolve — fail closed
|
||||
directory, sep, name = token.rpartition("/")
|
||||
if sep and directory not in _TRUSTED_EXECUTABLE_DIRS:
|
||||
return ""
|
||||
return name.removesuffix(".exe")
|
||||
|
||||
|
||||
def _denied_read_option(token: str, denied: frozenset) -> bool:
|
||||
"""True when an argument spells an execution/mutation option of its command."""
|
||||
if not token.startswith("-") or token in {"-", "--"}:
|
||||
return False
|
||||
name = token.split("=", 1)[0]
|
||||
if name in denied:
|
||||
return True
|
||||
if name.startswith("--"):
|
||||
return False
|
||||
return any(f"-{letter}" in denied for letter in name[1:]) # bundled short cluster
|
||||
|
||||
|
||||
_NESTED_EXECUTION_MARKERS = ("$(", "`", "<(", ">(")
|
||||
|
||||
|
||||
_NESTED_EXECUTION_TOKENS = frozenset({"$", "(", ")", "<(", ">(", "$("})
|
||||
|
||||
|
||||
def _is_pure_read_inspection(text_lower: str) -> bool:
|
||||
"""True when EVERY command in a shell line is a read-only source inspection.
|
||||
|
||||
Structural, not keyword-based: the line is split into per-command segments with
|
||||
the shared lexer (``shell_parse.shell_segments``) and each segment's HEAD is
|
||||
matched against an allowlist. An unknown head — any interpreter, HTTP client,
|
||||
or shell — is not an inspection, whatever flags or payload spelling it carries.
|
||||
|
||||
Head membership is NECESSARY, NOT SUFFICIENT (review round 2): an allowed head can
|
||||
still execute through its own options (``find -exec``, ``rg --pre``, git's external
|
||||
diff/textconv) or through what precedes it. So the options are validated per command
|
||||
(``_DENIED_READ_OPTIONS``), a leading environment assignment is REFUSED rather than
|
||||
dropped (``PATH=``/``LD_PRELOAD=``/``GIT_EXTERNAL_DIFF=`` change what actually runs),
|
||||
wrappers may not carry their own flags (``env -i``, ``sudo -e``), and the executable
|
||||
must resolve to a bare name or a system bin. Anything unrecognised stays fail-closed.
|
||||
|
||||
NESTED EXECUTION IS REFUSED BEFORE ANY OF THAT (review round 3). Only the heads the lexer
|
||||
actually surfaces get validated, so a command substitution hid its command from every check
|
||||
above: ``echo "$(curl -X POST .../api/owner/scope-review-floor)"`` presented the allowlisted
|
||||
``echo``, and the write-shape detector does not recognise an HTTP POST, so the exemption was
|
||||
granted to a line that existed to reach the owner-only endpoint. A quoted substitution is
|
||||
one opaque argument token to the lexer, which is why this is a check on the TEXT and on the
|
||||
tokens, not something the per-segment head walk could have caught.
|
||||
"""
|
||||
from ouroboros.shell_parse import shell_segments
|
||||
|
||||
if any(marker in text_lower for marker in _NESTED_EXECUTION_MARKERS):
|
||||
return False
|
||||
segments = shell_segments(text_lower)
|
||||
if not segments:
|
||||
return False
|
||||
for segment in segments:
|
||||
if any(token in _NESTED_EXECUTION_TOKENS for token in segment):
|
||||
return False
|
||||
tokens = [token for token in segment if token]
|
||||
while tokens and tokens[0] in _COMMAND_HEAD_WRAPPERS:
|
||||
tokens = tokens[1:]
|
||||
if tokens and tokens[0].startswith("-"):
|
||||
return False # a wrapper's own options can rebuild the environment
|
||||
if not tokens:
|
||||
continue # a bare wrapper executes nothing
|
||||
if "=" in tokens[0] and not tokens[0].startswith(("-", "=")):
|
||||
return False # leading env assignment: never silently discarded
|
||||
head = _trusted_read_head(tokens[0])
|
||||
if head == "git":
|
||||
if len(tokens) < 2 or tokens[1] not in _READ_ONLY_GIT_SUBCOMMANDS:
|
||||
return False
|
||||
elif not head or head not in _READ_ONLY_INSPECTION_COMMANDS:
|
||||
return False
|
||||
denied = _DENIED_READ_OPTIONS.get(head)
|
||||
if denied and any(_denied_read_option(token, denied) for token in tokens[1:]):
|
||||
return False
|
||||
if head == "uniq" and sum(1 for t in tokens[1:] if t == "-" or not t.startswith("-")) >= 2:
|
||||
# uniq's SECOND positional operand is its output file ('-' is the
|
||||
# stdin operand, not a flag): `... | uniq - settings.json` writes.
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _detect_scope_review_floor_self_lowering(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect shell/script attempts to REACH the owner-controlled scope-review floor
|
||||
(CW1, v6.34.0). ``OUROBOROS_SCOPE_REVIEW_FLOOR`` is deprecated and enforcement-inert
|
||||
since v6.80.0 (scope-review applicability follows the owner context mode), but it is
|
||||
still an owner-only stored setting behind its dedicated audited endpoint, so the agent
|
||||
must not write it through any channel. Mirrors the context-mode guard.
|
||||
|
||||
POLARITY (v6.80.0): naming the owner endpoint or the floor key in a settings context
|
||||
is blocked UNLESS the whole command line is demonstrably read-only inspection
|
||||
(``_is_pure_read_inspection``). The earlier shape — block only on a listed HTTP write
|
||||
marker — failed OPEN: ``python -c "httpx.request('POST', '.../api/owner/
|
||||
scope-review-floor', ...)"`` names the endpoint, matches no marker, and mutated the
|
||||
setting. No substring enumeration of "what a write looks like" is ever complete
|
||||
(BIBLE P5), so the enumeration was inverted to "what a read looks like", where an
|
||||
unrecognised entry is refused rather than admitted.
|
||||
|
||||
Pure source inspection stays allowed: ``grep OUROBOROS_SCOPE_REVIEW_FLOOR
|
||||
data/settings.json`` and ``rg '/api/owner/scope-review-floor' ouroboros/gateway``
|
||||
read and do not act. ``writeish`` is the shell guard's own already-computed
|
||||
write-shape fact (redirects, writer commands, write-mode ``open()``); it disqualifies
|
||||
the read exemption, and its default ``True`` keeps a caller that cannot supply the
|
||||
fact fail-closed."""
|
||||
import urllib.parse
|
||||
|
||||
decoded = urllib.parse.unquote(urllib.parse.unquote(text_lower)).lower()
|
||||
text = f"{text_lower} {decoded}"
|
||||
mentions_floor_key = "ouroboros_scope_review_floor" in text
|
||||
mentions_owner_endpoint = "/api/owner/scope-review-floor" in text
|
||||
mentions_floor_endpoint = "scope-review-floor" in text and "/api/owner" in text
|
||||
mentions_floor_cli = "scope-review-floor" in text and (
|
||||
"ouroboros settings" in text
|
||||
or "ouroboros.cli" in text
|
||||
)
|
||||
mentions_save = "save_settings" in text or "settings.json" in text or "/api/settings" in text
|
||||
reaches_floor = (
|
||||
mentions_owner_endpoint
|
||||
or mentions_floor_endpoint
|
||||
or mentions_floor_cli
|
||||
or (mentions_floor_key and mentions_save)
|
||||
)
|
||||
if not reaches_floor:
|
||||
return False
|
||||
return writeish or not _is_pure_read_inspection(text_lower)
|
||||
|
||||
|
||||
def _detect_safety_mode_self_lowering(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect shell/script attempts to lower the owner-controlled LLM-safety coverage
|
||||
(v6.54.3). OUROBOROS_SAFETY_MODE gates the LLM safety supervisor layer; the agent
|
||||
must not switch it to ``light``/``off`` to remove its own supervision friction
|
||||
(BIBLE P3). Mirrors the context-mode/scope-floor self-lowering guards."""
|
||||
import urllib.parse
|
||||
|
||||
# URL-decode so a percent-encoded endpoint (curl .../safety%2Dmode) cannot
|
||||
# slip the literal scan (review round 6; mirrors the attest-endpoint guard).
|
||||
decoded = urllib.parse.unquote(urllib.parse.unquote(text_lower)).lower()
|
||||
text = f"{text_lower} {decoded}"
|
||||
mentions_mode_key = "ouroboros_safety_mode" in text
|
||||
mentions_owner_endpoint = "/api/owner/safety-mode" in text
|
||||
mentions_mode_endpoint = "safety-mode" in text and "/api/owner" in text
|
||||
mentions_mode_cli = "safety-mode" in text and (
|
||||
"ouroboros settings" in text
|
||||
or "ouroboros.cli" in text
|
||||
)
|
||||
mentions_save = "save_settings" in text or "settings.json" in text or "/api/settings" in text
|
||||
detected = (
|
||||
mentions_owner_endpoint
|
||||
or mentions_mode_endpoint
|
||||
or mentions_mode_cli
|
||||
or (mentions_mode_key and mentions_save)
|
||||
)
|
||||
return _registry()._owner_control_mention_blocks(text_lower, detected, writeish)
|
||||
|
||||
|
||||
def _detect_owner_skill_attest_self_call(text_lower: str, *, writeish: bool = True) -> bool:
|
||||
"""Detect agent attempts to loopback-call the OWNER-ONLY skill owner-attestation endpoint
|
||||
(C1, v6.39). Owner-attestation skips the expensive LLM skill review; it MUST be
|
||||
owner-issued, never agent self-callable — otherwise the agent could self-bypass the
|
||||
immune system's review for its own skill. Mirrors the context-mode/scope-floor guards.
|
||||
|
||||
URL-DECODE first so a percent-encoded path (e.g. ``%61ttest-review`` / ``attest%2Dreview``)
|
||||
— which Starlette decodes back to ``attest-review`` before routing — cannot slip past the
|
||||
literal match (decode twice to catch double-encoding)."""
|
||||
import urllib.parse
|
||||
decoded = urllib.parse.unquote(urllib.parse.unquote(text_lower)).lower()
|
||||
text = f"{text_lower} {decoded}"
|
||||
detected = "/api/owner/skills/" in text and "attest-review" in text
|
||||
return _registry()._owner_control_mention_blocks(text_lower, detected, writeish)
|
||||
|
||||
|
||||
_SKILL_OWNER_STATE_STEMS = SKILL_OWNER_STATE_STEMS
|
||||
|
||||
|
||||
_DETACHED_PROCESS_MARKERS = ("start_new_session", "new_session", "setsid", "preexec_fn", "nohup")
|
||||
|
||||
|
||||
def _mentions_skill_owner_state(text_lower: str) -> bool:
|
||||
if "state" not in text_lower or "skills" not in text_lower:
|
||||
return False
|
||||
for stem in _SKILL_OWNER_STATE_STEMS:
|
||||
if f"{stem}.json" in text_lower:
|
||||
return True
|
||||
if stem in text_lower and ".json" in text_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _mentions_detached_process(text_lower: str) -> bool:
|
||||
return any(marker in text_lower for marker in _DETACHED_PROCESS_MARKERS)
|
||||
|
||||
|
||||
def _light_repo_snapshot(repo_dir: pathlib.Path) -> Optional[Dict[str, Any]]:
|
||||
"""Worktree tripwire for light-mode shell writes, not rollback machinery."""
|
||||
try:
|
||||
repo = pathlib.Path(repo_dir)
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if status.returncode != 0:
|
||||
return None
|
||||
unstaged = subprocess.run(
|
||||
["git", "diff", "--binary", "--no-ext-diff"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
staged = subprocess.run(
|
||||
["git", "diff", "--cached", "--binary", "--no-ext-diff"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
paths = _registry().parse_porcelain_paths(status.stdout)
|
||||
digest = hashlib.sha256()
|
||||
digest.update((status.stdout or "").encode("utf-8", errors="replace"))
|
||||
digest.update((unstaged.stdout if unstaged.returncode == 0 else "").encode("utf-8", errors="replace"))
|
||||
digest.update((staged.stdout if staged.returncode == 0 else "").encode("utf-8", errors="replace"))
|
||||
for rel in paths:
|
||||
try:
|
||||
target = (repo / _registry().safe_relpath(rel)).resolve(strict=False)
|
||||
target.relative_to(repo.resolve(strict=False))
|
||||
if target.is_file() and rel in (status.stdout or ""):
|
||||
stat = target.stat()
|
||||
digest.update(f"{rel}\0{stat.st_size}\0{stat.st_mtime_ns}".encode("utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
return {"digest": digest.hexdigest(), "paths": paths}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _format_light_repo_write_block(before: Dict[str, Any], after: Dict[str, Any], result: str, tool_name: str = "run_command") -> str:
|
||||
before_paths = set(before.get("paths") or [])
|
||||
after_paths = set(after.get("paths") or [])
|
||||
touched = sorted(after_paths | before_paths)
|
||||
listed = ", ".join(touched[:30]) if touched else "(status changed; no paths parsed)"
|
||||
if len(touched) > 30:
|
||||
listed += f", ... (+{len(touched) - 30} more)"
|
||||
return (
|
||||
"⚠️ LIGHT_MODE_REPO_WRITE_BLOCKED: runtime_mode=light detected "
|
||||
f"a mutation of the Ouroboros repository after {tool_name}. "
|
||||
"The command result is blocked and no automatic rollback was attempted "
|
||||
"to avoid overwriting concurrent human edits. "
|
||||
f"Affected/dirty paths: {listed}. Switch to advanced/pro for repo writes.\n\n"
|
||||
"Original command output:\n"
|
||||
f"{result}"
|
||||
)
|
||||
|
||||
|
||||
def _git_ref_snapshot(repo_dir: pathlib.Path) -> Optional[Dict[str, str]]:
|
||||
try:
|
||||
repo = pathlib.Path(repo_dir)
|
||||
head = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
refs = subprocess.run(
|
||||
["git", "show-ref", "--head", "--dereference"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if head.returncode != 0 or refs.returncode not in (0, 1):
|
||||
return None
|
||||
digest = hashlib.sha256()
|
||||
digest.update((head.stdout or "").encode("utf-8", errors="replace"))
|
||||
digest.update((refs.stdout or "").encode("utf-8", errors="replace"))
|
||||
return {"head": (head.stdout or "").strip(), "digest": digest.hexdigest()}
|
||||
except Exception:
|
||||
return None
|
||||
418
ouroboros/tools/registry_guards.py
Normal file
418
ouroboros/tools/registry_guards.py
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
"""Host-owned pre-dispatch guards: capability/resource, ephemeral, managed-update and skill-payload constraints.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
# The logger name is pinned to the parent's literal namespace so the
|
||||
# extraction does not silently rename the log stream.
|
||||
log = logging.getLogger("ouroboros.tools.registry")
|
||||
|
||||
|
||||
def _registry():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros.tools import registry
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
def _executor_backend_candidate_allowed(ctx: Any, candidate: str, allowed_roots: List[pathlib.Path]) -> bool:
|
||||
try:
|
||||
from ouroboros.workspace_executor import executor_ref_from_ctx as _executor_ref_from_ctx
|
||||
from ouroboros.workspace_executor import map_backend_path as _executor_map_backend_path
|
||||
|
||||
executor_ref = _executor_ref_from_ctx(ctx)
|
||||
if executor_ref is None:
|
||||
return False
|
||||
resolved = _executor_map_backend_path(executor_ref, candidate)
|
||||
return any(
|
||||
resolved.is_relative_to(root) or _registry()._path_is_relative_to_casefold(resolved, root)
|
||||
for root in allowed_roots
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _command_mentions_protected_root(cmd_path_lower: str, root_text: str) -> bool:
|
||||
"""Boundary-aware path containment for the workspace shell guard.
|
||||
|
||||
True only when ``root_text`` (a normalised, lower-cased protected root path)
|
||||
appears in the command as a whole path or a parent prefix at a real path
|
||||
boundary — NOT as an incidental substring of an unrelated path that merely
|
||||
shares the prefix (e.g. protected ``/x/data`` must not match ``/x/database``).
|
||||
Used as a coarse catch-all for runtime paths embedded in non-tokenised text
|
||||
(e.g. inside a ``python -c`` string); the precise per-token containment loop
|
||||
still does the authoritative active/protected classification.
|
||||
"""
|
||||
if not root_text:
|
||||
return False
|
||||
norm = root_text.rstrip("/")
|
||||
if not norm:
|
||||
return False
|
||||
span = len(norm)
|
||||
limit = len(cmd_path_lower)
|
||||
start = 0
|
||||
while True:
|
||||
idx = cmd_path_lower.find(norm, start)
|
||||
if idx < 0:
|
||||
return False
|
||||
end = idx + span
|
||||
nxt = cmd_path_lower[end] if end < limit else ""
|
||||
# Boundary = end-of-string, a path separator (child path), or a shell
|
||||
# token delimiter (the exact path). A trailing path char (letter/digit/
|
||||
# ``.``/``-``/``_``) means a DIFFERENT sibling path → keep scanning.
|
||||
if nxt == "" or nxt == "/" or nxt in " \t\"')(;:,&|<>":
|
||||
return True
|
||||
start = end
|
||||
|
||||
|
||||
def _stray_skill_payload_failsoft(root_arg: str, workspace_mode: bool, task_constraint: Any) -> bool:
|
||||
"""Whether stray bucket/skill_name on a write tool should be DROPPED rather than
|
||||
surfaced as SKILL_PAYLOAD_ARG_ERROR. Fail-soft ONLY for a WORKSPACE edit that is
|
||||
NOT skill-authoring: there bucket/skill_name are model noise (the B2 footgun —
|
||||
reflexive bucket="external" on an /app edit). In light/advanced non-workspace
|
||||
skill-authoring (or an explicit root=skill_payload / skill_repair) the specific
|
||||
error is the intended helpful signal."""
|
||||
skill_payload_intent = root_arg == "skill_payload" or bool(
|
||||
task_constraint and getattr(task_constraint, "mode", "") == "skill_repair"
|
||||
)
|
||||
return bool(workspace_mode and not skill_payload_intent)
|
||||
|
||||
|
||||
def _managed_update_code_tool_block(ctx: Any, name: str) -> str:
|
||||
"""Block a repo-mutating code tool while a managed-update assisted merge is staged for
|
||||
ANOTHER task (P2/SC2). Returns a block message, or "" when allowed (this is the authorized
|
||||
resolution task, or no managed tx is active). A corrupt tx marker fails closed."""
|
||||
try:
|
||||
from supervisor.update_merge import managed_assisted_tx_for
|
||||
|
||||
if managed_assisted_tx_for(
|
||||
getattr(ctx, "task_id", ""),
|
||||
getattr(ctx, "task_metadata", None),
|
||||
)[1]:
|
||||
return (
|
||||
f"⚠️ MANAGED_UPDATE_IN_PROGRESS: {name!r} is blocked while a managed update merge "
|
||||
"is being resolved (only its authorized resolution task may write the repo). "
|
||||
"Retry after the update lands or is rolled back."
|
||||
)
|
||||
except Exception:
|
||||
return (
|
||||
f"⚠️ MANAGED_UPDATE_STATE_UNAVAILABLE: {name!r} is blocked because the managed "
|
||||
"update transaction state could not be verified. Retry after the update state is "
|
||||
"available or repaired."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _authorized_managed_update_resolver(ctx: Any) -> bool:
|
||||
"""Whether this task is the durable tx-authorized assisted resolver.
|
||||
|
||||
Fail-closed bool for every authority consumer (False = no extra powers).
|
||||
The AUTHORITY-READ failure is additionally distinguished from an honest
|
||||
"not the resolver" via a typed ctx marker (``_managed_authority_read_error``:
|
||||
set on an unreadable read AND on a corrupt tx marker, cleared on every
|
||||
healthy evaluation), so the review-subject builder can fail LOUDLY instead
|
||||
of silently reviewing a possibly-managed candidate as an ordinary full
|
||||
staged capture."""
|
||||
try:
|
||||
from supervisor.update_merge import authorized_assisted_task_strict
|
||||
|
||||
marker_status, tx = authorized_assisted_task_strict(
|
||||
getattr(ctx, "task_id", ""),
|
||||
getattr(ctx, "task_metadata", None),
|
||||
)
|
||||
try:
|
||||
if marker_status == "corrupt":
|
||||
# A tx marker EXISTS but cannot be parsed: authority stays
|
||||
# False (fail-closed) for every bool consumer, but the loud
|
||||
# A4 channel must fire — clearing the marker here would let
|
||||
# the review subject silently treat a possibly-managed
|
||||
# candidate as an ordinary full staged diff.
|
||||
setattr(
|
||||
ctx, "_managed_authority_read_error",
|
||||
"update_tx_corrupt: the managed update transaction marker "
|
||||
"exists but could not be parsed",
|
||||
)
|
||||
else:
|
||||
setattr(ctx, "_managed_authority_read_error", "")
|
||||
except Exception:
|
||||
pass
|
||||
return bool(tx)
|
||||
except Exception as exc:
|
||||
try:
|
||||
setattr(ctx, "_managed_authority_read_error", repr(exc))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _task_constraint_path_allowed(path_text: str, constraint: Optional[TaskConstraint], drive_root: pathlib.Path) -> bool:
|
||||
return _registry().is_skill_payload_path(
|
||||
drive_root,
|
||||
path_text or "",
|
||||
constraint=constraint,
|
||||
allow_short_relative=True,
|
||||
allow_control_plane=True,
|
||||
)
|
||||
|
||||
|
||||
def _light_mode_payload_mutation_allowed(
|
||||
*,
|
||||
ctx: Any,
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
runtime_mode: str,
|
||||
effective_constraint: Optional[TaskConstraint],
|
||||
implicit_skill_cwd_allowed: bool,
|
||||
allow_short_relative: bool,
|
||||
) -> bool:
|
||||
"""Return True for light-mode data skill payload edits that do not touch repo files."""
|
||||
|
||||
# apply_patch/edit_batch are DELIBERATELY absent: they refuse data-plane roots
|
||||
# entirely (repo lanes only), so they can never be a payload edit — in light
|
||||
# mode they stay under the generic repo-mutation block like any repo write.
|
||||
if runtime_mode != "light" or tool_name not in {"edit_text", "write_file"}:
|
||||
return False
|
||||
requested_root = str(args.get("root", "") or "active_workspace")
|
||||
try:
|
||||
requested_root = _registry().normalize_root(requested_root)
|
||||
except Exception:
|
||||
requested_root = str(args.get("root", "") or "active_workspace")
|
||||
if requested_root in {"task_drive", "artifact_store", "user_files"}:
|
||||
return True
|
||||
legacy_data_skill_edit = False
|
||||
if tool_name == "edit_text" and requested_root == "active_workspace":
|
||||
try:
|
||||
legacy_target = _registry().resolve_skill_payload_target(
|
||||
pathlib.Path(ctx.drive_root),
|
||||
str(args.get("path", "") or ""),
|
||||
)
|
||||
legacy_data_skill_edit = legacy_target.target_path.exists() and not legacy_target.control_plane
|
||||
except Exception:
|
||||
legacy_data_skill_edit = False
|
||||
if requested_root not in {"runtime_data", "skill_payload"} and not legacy_data_skill_edit:
|
||||
return False
|
||||
return _registry().is_skill_payload_path(
|
||||
pathlib.Path(ctx.drive_root),
|
||||
str(args.get("path", "") or ""),
|
||||
constraint=effective_constraint,
|
||||
allow_short_relative=allow_short_relative,
|
||||
allow_control_plane=False,
|
||||
)
|
||||
|
||||
|
||||
_HEAL_MODE_ALLOWED_TOOLS = frozenset({
|
||||
"read_file",
|
||||
"list_files",
|
||||
"write_file",
|
||||
"edit_text",
|
||||
"list_skills",
|
||||
"skill_review", "skill_preflight",
|
||||
})
|
||||
|
||||
|
||||
def _heal_protected_payload_sidecar(path_text: str) -> bool:
|
||||
return _registry().is_skill_payload_control_filename(path_text)
|
||||
|
||||
|
||||
_WEB_TOOLS = frozenset({"web_search", "browse_page", "browser_action", "youtube_transcript"})
|
||||
|
||||
|
||||
def _resource_allowed(ctx: Any, key: str) -> bool:
|
||||
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
|
||||
contract = metadata.get("task_contract") if isinstance(metadata.get("task_contract"), dict) else {}
|
||||
if not contract and isinstance(getattr(ctx, "task_contract", None), dict):
|
||||
contract = getattr(ctx, "task_contract")
|
||||
resources = {}
|
||||
for source in (metadata, contract):
|
||||
raw = source.get("allowed_resources") if isinstance(source, dict) else None
|
||||
if isinstance(raw, dict):
|
||||
resources.update(raw)
|
||||
if not resources:
|
||||
return True
|
||||
for name in (key, f"allow_{key}"):
|
||||
value = resources.get(name)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if key == "web":
|
||||
for name in ("network", "allow_network", "internet", "external_network"):
|
||||
value = resources.get(name)
|
||||
if isinstance(value, bool) and not value:
|
||||
return False
|
||||
if key == "network":
|
||||
for name in ("web", "allow_web", "internet", "external_network"):
|
||||
value = resources.get(name)
|
||||
if isinstance(value, bool) and not value:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _disabled_tools(ctx: Any) -> frozenset:
|
||||
"""Tool names the task contract withholds (declarative tool policy).
|
||||
|
||||
Independent of ``allowed_resources``: a caller can disable specific tools
|
||||
(e.g. the agent's web_search/browser/VLM tools for a faithful benchmark)
|
||||
WITHOUT setting web/network=false — so shell network egress (git/pip) stays
|
||||
available and the web<->network cross-implication in ``_resource_allowed``
|
||||
never fires.
|
||||
"""
|
||||
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
|
||||
contract = metadata.get("task_contract") if isinstance(metadata.get("task_contract"), dict) else {}
|
||||
if not contract and isinstance(getattr(ctx, "task_contract", None), dict):
|
||||
contract = getattr(ctx, "task_contract")
|
||||
names: set = set()
|
||||
for source in (metadata, contract):
|
||||
raw = source.get("disabled_tools") if isinstance(source, dict) else None
|
||||
if isinstance(raw, (list, tuple)):
|
||||
names.update(str(n).strip() for n in raw if str(n).strip())
|
||||
# D10 compatibility: `claude_code_edit` was retired; saved contracts that
|
||||
# withheld the external coding gateway keep withholding its SUCCESSOR — the
|
||||
# delegated coding session's start verb. The dead name stays in the set
|
||||
# too (harmless: nothing registers it), so old contracts round-trip as-is.
|
||||
if "claude_code_edit" in names:
|
||||
names.add("delegate_start")
|
||||
# Q1 rename compatibility: contracts that withheld `advisory_review` keep
|
||||
# withholding the SAME organ under its new name, and vice versa (a new
|
||||
# contract naming only the new spelling must also silence the alias).
|
||||
if "advisory_review" in names:
|
||||
names.add("preflight_review")
|
||||
if "preflight_review" in names:
|
||||
names.add("advisory_review")
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
_GITHUB_TOKEN_TOOLS = frozenset({
|
||||
"list_github_prs",
|
||||
"get_github_pr",
|
||||
"comment_on_pr",
|
||||
"list_github_issues",
|
||||
"get_github_issue",
|
||||
"comment_on_issue",
|
||||
"close_github_issue",
|
||||
"create_github_issue",
|
||||
"run_ci_tests",
|
||||
"submit_skill_to_hub",
|
||||
"generate_evolution_stats",
|
||||
})
|
||||
|
||||
|
||||
def _builtin_tool_availability(name: str, ctx: Any = None) -> tuple[bool, str, str]:
|
||||
"""Return ``(available, reason, detail)`` for built-in tool credential gates.
|
||||
|
||||
Predicates are lazy to avoid registry import cycles and discovery-time side effects.
|
||||
"""
|
||||
# A bare registry (unit tests, static policy inventory, import-time introspection)
|
||||
# is a structural surface, not a running task capability envelope.
|
||||
if not str(getattr(ctx, "task_id", "") or "").strip():
|
||||
metadata = getattr(ctx, "task_metadata", {}) if ctx is not None else {}
|
||||
contract = getattr(ctx, "task_contract", {}) if ctx is not None else {}
|
||||
if not metadata and not contract:
|
||||
return True, "", ""
|
||||
tool = str(name or "").strip()
|
||||
if tool == "web_search":
|
||||
try:
|
||||
from ouroboros.tools.search import _available_web_search_backends
|
||||
|
||||
if not _available_web_search_backends():
|
||||
return False, "missing_credential", "web_search_backend"
|
||||
except ImportError:
|
||||
return True, "", ""
|
||||
except Exception:
|
||||
return True, "", ""
|
||||
if tool in _GITHUB_TOKEN_TOOLS and not os.environ.get("GITHUB_TOKEN", "").strip():
|
||||
return False, "missing_credential", "GITHUB_TOKEN"
|
||||
return True, "", ""
|
||||
|
||||
|
||||
def _payload_dispatch_constraint(
|
||||
ctx: Any,
|
||||
*,
|
||||
name: str,
|
||||
args: dict[str, Any],
|
||||
task_constraint: Optional[TaskConstraint],
|
||||
workspace_mode: bool,
|
||||
) -> tuple[Optional[TaskConstraint], str]:
|
||||
"""Preserve repair selectors without letting stray selectors retarget work."""
|
||||
|
||||
raw_bucket = str(args.get("bucket", "") or "")
|
||||
raw_skill_name = str(args.get("skill_name", "") or "")
|
||||
explicit_skill_root = str(args.get("root", "") or "").strip().lower() == "skill_payload"
|
||||
short_form_decision = None if explicit_skill_root else _registry().decide_payload_short_form(
|
||||
bucket=raw_bucket,
|
||||
skill_name=raw_skill_name,
|
||||
path_text=str(args.get("path", "") or "."),
|
||||
repo_dir=pathlib.Path(ctx.repo_dir),
|
||||
drive_root=pathlib.Path(ctx.drive_root),
|
||||
)
|
||||
if explicit_skill_root:
|
||||
# Binding selection already handled the explicit target. This legacy
|
||||
# constraint exists only for the light-mode data-payload carve-out.
|
||||
synthesized = _registry().synthesize_payload_constraint(raw_bucket, raw_skill_name)
|
||||
else:
|
||||
synthesized = (
|
||||
short_form_decision.constraint
|
||||
if short_form_decision is not None
|
||||
and task_constraint
|
||||
and task_constraint.mode == "skill_repair"
|
||||
else None
|
||||
)
|
||||
|
||||
if (
|
||||
(raw_bucket or raw_skill_name)
|
||||
and short_form_decision is not None
|
||||
and short_form_decision.error
|
||||
and name in {"write_file", "edit_text"}
|
||||
):
|
||||
root_arg = str(args.get("root", "") or "").strip().lower()
|
||||
if _stray_skill_payload_failsoft(root_arg, workspace_mode, task_constraint):
|
||||
log.info(
|
||||
"Ignoring stray bucket/skill_name on %s (workspace edit, root=%s): %s",
|
||||
name,
|
||||
root_arg or "active_workspace",
|
||||
short_form_decision.error[:80],
|
||||
)
|
||||
args.pop("bucket", None)
|
||||
args.pop("skill_name", None)
|
||||
synthesized = None
|
||||
else:
|
||||
return None, f"⚠️ SKILL_PAYLOAD_ARG_ERROR: {short_form_decision.error}"
|
||||
|
||||
redirect_err = _registry().cross_skill_redirect_error(task_constraint, synthesized)
|
||||
if redirect_err and name in {"write_file", "edit_text"}:
|
||||
return None, f"⚠️ SKILL_REDIRECT_BLOCKED: {redirect_err}"
|
||||
if task_constraint and task_constraint.mode == "skill_repair":
|
||||
return task_constraint, ""
|
||||
return synthesized or task_constraint, ""
|
||||
|
||||
|
||||
_EPHEMERAL_ALLOWED_TOOLS = frozenset({
|
||||
# read / inspect
|
||||
"read_file", "query_code", "search_code", "list_files", "web_search", "browse_page",
|
||||
"chat_history", "recent_tasks", "get_task_result", "vcs_diff", "vcs_status",
|
||||
"analyze_screenshot", "vlm_query",
|
||||
# decide / route / spawn-owner-task / reply
|
||||
"route_to_project", "promote_chat_to_task", "steer_task", "list_projects", "send_photo",
|
||||
})
|
||||
53
ouroboros/tools/tool_catalog.py
Normal file
53
ouroboros/tools/tool_catalog.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""The immutable first-party tool descriptor (ToolEntry).
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def _registry():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros.tools import registry
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolEntry:
|
||||
"""Single tool descriptor."""
|
||||
|
||||
name: str
|
||||
schema: Dict[str, Any]
|
||||
handler: Callable # fn(ctx: ToolContext, **args) -> str
|
||||
is_code_tool: bool = False
|
||||
timeout_sec: int = 360
|
||||
# Capability flag: tool can mutate the live repo worktree. The dispatcher
|
||||
# snapshots `git status --porcelain` around flagged tools and invalidates
|
||||
# advisory freshness when the worktree ACTUALLY changed — covering error
|
||||
# and timeout paths uniformly, and never invalidating for read-only runs.
|
||||
mutates_worktree: bool = False
|
||||
# Compatibility alias: a renamed tool's old public name. An alias entry is
|
||||
# CALLABLE (execute dispatches it like any entry) but never advertised —
|
||||
# schemas()/available_tools() skip it, so the public surface carries only
|
||||
# the canonical name while saved prompts, memories, and configs that still
|
||||
# use the old spelling keep working.
|
||||
alias_for: str = ""
|
||||
162
ouroboros/tools/tool_context.py
Normal file
162
ouroboros/tools/tool_context.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Concrete per-task context shared by tool handlers and the registry facade.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _registry():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros.tools import registry
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserState:
|
||||
"""Per-task Playwright lifecycle state."""
|
||||
|
||||
pw_instance: Any = None
|
||||
browser: Any = None
|
||||
page: Any = None
|
||||
last_screenshot_b64: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
"""Tool execution context passed from the agent."""
|
||||
|
||||
repo_dir: pathlib.Path
|
||||
drive_root: pathlib.Path
|
||||
branch_dev: str = "ouroboros"
|
||||
system_repo_dir: Optional[pathlib.Path] = None
|
||||
workspace_root: Optional[pathlib.Path] = None
|
||||
workspace_mode: str = ""
|
||||
memory_mode: str = ""
|
||||
budget_drive_root: str = ""
|
||||
# Per-project facts scope (Phase 3b): when set, knowledge reads/writes target
|
||||
# the per-project store under the canonical data dir instead of memory/knowledge.
|
||||
project_id: str = ""
|
||||
task_metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
executor_ref: Dict[str, Any] = field(default_factory=dict)
|
||||
pending_events: List[Dict[str, Any]] = field(default_factory=list)
|
||||
current_chat_id: Optional[int] = None
|
||||
current_task_type: Optional[str] = None
|
||||
pending_restart_reason: Optional[str] = None
|
||||
last_push_succeeded: bool = False
|
||||
last_reviewed_commit_sha: str = ""
|
||||
emit_progress_fn: Callable[[str], None] = field(default=lambda _: None)
|
||||
|
||||
# LLM-driven model/effort switch.
|
||||
active_model_override: Optional[str] = None
|
||||
active_effort_override: Optional[str] = None
|
||||
active_use_local_override: Optional[bool] = None
|
||||
task_model_override: Optional[str] = None
|
||||
task_use_local_override: Optional[bool] = None
|
||||
# CW2 (v6.34.0): the loop publishes the effective context mode each round so
|
||||
# switch_model can refuse switching to a sub-1M route while the transcript is max-sized.
|
||||
active_context_mode: str = ""
|
||||
|
||||
# Per-task browser state.
|
||||
browser_state: BrowserState = field(default_factory=BrowserState)
|
||||
|
||||
# Budget tracking for usage events.
|
||||
event_queue: Optional[Any] = None
|
||||
task_id: Optional[str] = None
|
||||
|
||||
# Conversation messages for safety checks.
|
||||
messages: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Structured task constraints, e.g. skill repair payload confinement.
|
||||
task_constraint: Optional[TaskConstraint] = None
|
||||
task_contract: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Task depth for fork-bomb protection.
|
||||
task_depth: int = 0
|
||||
|
||||
# True inside handle_chat_direct, not a queued worker task.
|
||||
is_direct_chat: bool = False
|
||||
# CW3 (v6.34.0): a SHORT-LIVED same-route "decision" turn (run while the chat
|
||||
# agent is busy). It may answer / route / spawn / steer, but is barred from
|
||||
# durable cognitive-memory / evolution / settings / control-plane mutators
|
||||
# (the WS10 ephemeral contract) — enforced in schemas()/execute().
|
||||
is_ephemeral_turn: bool = False
|
||||
|
||||
# Pre-commit review state.
|
||||
_review_advisory: List[Any] = field(default_factory=list)
|
||||
_review_iteration_count: int = 0
|
||||
_review_history: list = field(default_factory=list)
|
||||
|
||||
def active_repo_dir(self) -> pathlib.Path:
|
||||
if self.is_workspace_mode():
|
||||
return pathlib.Path(self.workspace_root)
|
||||
return pathlib.Path(self.repo_dir)
|
||||
|
||||
def is_workspace_mode(self) -> bool:
|
||||
return (
|
||||
self.workspace_root is not None
|
||||
and bool(str(self.workspace_mode or "").strip())
|
||||
and not _registry().workspace_mode_block_reason(self)
|
||||
)
|
||||
|
||||
def repo_path(self, rel: str) -> pathlib.Path:
|
||||
root = self.active_repo_dir()
|
||||
# Accept the paths an agent naturally writes against a workspace root:
|
||||
# an absolute path already INSIDE the root (e.g. /app/out.txt under a
|
||||
# workspace rooted at /app — otherwise re-nested as /app/app/out.txt) and
|
||||
# a redundant root-basename prefix ('app/out.txt'). normalize_root_relative
|
||||
# only ever returns a relative string; paths not under the root fall
|
||||
# through to safe_relpath (kept inside) and the boundary check below.
|
||||
rel_str = _registry().normalize_root_relative(root, str(rel))
|
||||
resolved = (root / _registry().safe_relpath(rel_str)).resolve()
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"Path escapes repo_dir boundary: {rel}")
|
||||
return resolved
|
||||
|
||||
def drive_path(self, rel: str) -> pathlib.Path:
|
||||
resolved = (self.drive_root / _registry().safe_relpath(rel)).resolve()
|
||||
try:
|
||||
resolved.relative_to(self.drive_root.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"Path escapes drive_root boundary: {rel}")
|
||||
return resolved
|
||||
|
||||
def drive_logs(self) -> pathlib.Path:
|
||||
return (self.drive_root / "logs").resolve()
|
||||
|
||||
def task_drive_root(self) -> pathlib.Path:
|
||||
return (pathlib.Path(self.drive_root).resolve(strict=False) / "task_drives" / _registry().task_id_for_artifacts(self)).resolve(strict=False)
|
||||
|
||||
def workspace_executor_ref(self) -> Dict[str, Any]:
|
||||
if isinstance(self.executor_ref, dict) and self.executor_ref:
|
||||
return dict(self.executor_ref)
|
||||
if isinstance(self.task_metadata, dict) and isinstance(self.task_metadata.get("executor_ref"), dict):
|
||||
return dict(self.task_metadata["executor_ref"])
|
||||
return {}
|
||||
496
ouroboros/tools/tool_resolution.py
Normal file
496
ouroboros/tools/tool_resolution.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
"""Argument normalization and physical target binding for tool dispatch.
|
||||
|
||||
Every span is extracted VERBATIM from the parent's tip bytes by
|
||||
scripts/v7next_transplant.py (D18/D33 module-handle split, proof-checked);
|
||||
the parent re-exports every moved name, so historical imports and
|
||||
monkeypatch targets keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
|
||||
from ouroboros.tools.tool_catalog import ToolEntry
|
||||
|
||||
|
||||
def _registry():
|
||||
"""The parent module, read at call time.
|
||||
|
||||
The parent owns the rebindable module state and the members tests
|
||||
monkeypatch there; reading them through the module at each call keeps
|
||||
one binding, where a from-import would freeze the value this leaf saw
|
||||
at import time (the owner-approved D18/D33 mechanical exception).
|
||||
"""
|
||||
from ouroboros.tools import registry
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
def _coerce_real_path(value: Any) -> pathlib.Path | None:
|
||||
if value is None or value.__class__.__module__.startswith("unittest.mock"):
|
||||
return None
|
||||
try:
|
||||
return pathlib.Path(os.fspath(value))
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def active_repo_dir_for(ctx: Any) -> pathlib.Path:
|
||||
"""Return the active repo/workspace root for real and lightweight test contexts."""
|
||||
active = getattr(ctx, "active_repo_dir", None)
|
||||
if callable(active):
|
||||
try:
|
||||
candidate = active()
|
||||
except Exception:
|
||||
candidate = None
|
||||
path = _coerce_real_path(candidate)
|
||||
if path is not None:
|
||||
return path
|
||||
|
||||
workspace_root = getattr(ctx, "workspace_root", None)
|
||||
workspace_path = _coerce_real_path(workspace_root)
|
||||
if workspace_path is not None:
|
||||
workspace_mode = str(getattr(ctx, "workspace_mode", "") or "").strip()
|
||||
if workspace_mode:
|
||||
return workspace_path
|
||||
|
||||
return pathlib.Path(getattr(ctx, "repo_dir"))
|
||||
|
||||
|
||||
def system_repo_dir_for(ctx: Any) -> pathlib.Path:
|
||||
"""Return the Ouroboros system repo root, not an external active workspace."""
|
||||
|
||||
return pathlib.Path(getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir"))
|
||||
|
||||
|
||||
_PATH_NORMALIZED_TOOLS = frozenset({"read_file", "write_file", "edit_text", "list_files", "search_code", "query_code"})
|
||||
|
||||
|
||||
_ROOT_ARG_REPO_WRITE_TOOLS = frozenset({"write_file", "edit_text", "apply_patch", "edit_batch"})
|
||||
|
||||
|
||||
def _payload_write_paths(name: str, args: Dict[str, Any]) -> List[str]:
|
||||
"""Repo paths a write tool will touch, in the spelling its guards must judge.
|
||||
|
||||
write_file/edit_text carry `path`/`files[]` and were already canonicalized by
|
||||
`_normalize_dispatch_path_args`. apply_patch addresses files inside the patch
|
||||
text (`*** Update File: <path>`) and edit_batch inside `edits[]`, so their
|
||||
paths reach this point RAW and are canonicalized here — otherwise a
|
||||
protected-path gate reads `repo/BIBLE.md` (not a protected-table member)
|
||||
while the write lands on `BIBLE.md`.
|
||||
"""
|
||||
|
||||
paths: List[str] = []
|
||||
if name == "write_file":
|
||||
if isinstance(args.get("path"), str) and args["path"]:
|
||||
paths.append(args["path"])
|
||||
for entry in args.get("files") or []:
|
||||
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
|
||||
paths.append(entry["path"])
|
||||
elif name == "edit_text":
|
||||
if isinstance(args.get("path"), str):
|
||||
paths.append(args["path"])
|
||||
elif name == "edit_batch":
|
||||
for entry in args.get("edits") or []:
|
||||
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
|
||||
paths.append(entry["path"])
|
||||
elif name == "apply_patch":
|
||||
# Derived from the REAL parser (lazy import: edit_ops imports this
|
||||
# module), so the gate can never drift from what apply_patch will do.
|
||||
# An unparseable patch yields no paths and is refused by the handler
|
||||
# before any write, so the gate has nothing to miss.
|
||||
from ouroboros.tools.edit_ops import patch_target_paths
|
||||
|
||||
paths.extend(patch_target_paths(str(args.get("patch") or "")))
|
||||
return [p for p in paths if str(p or "").strip()]
|
||||
|
||||
|
||||
def _normalize_dispatch_path_args(ctx: Any, name: str, args: Dict[str, Any]) -> str:
|
||||
"""ROOT-FIX (v6.35.0): normalize an absolute / redundant-root-basename
|
||||
active_workspace|system_repo path arg IN PLACE at the dispatch boundary, so
|
||||
the handler AND every downstream guard (protected-path, protected-artifact,
|
||||
accidental-truncation shrink guard) resolve the SAME target. One authoritative
|
||||
normalization point is what makes a guard unable to desync from the operation.
|
||||
|
||||
v6.54.3 root-label fix: returns a dispatch note ("" when nothing rerouted).
|
||||
When ``root='user_files'`` carries an ABSOLUTE path that resolves under the
|
||||
ACTIVE WORKSPACE root, the root label is wrong, not the intent: reads
|
||||
(read_file/list_files/search_code) are auto-routed to
|
||||
``root='active_workspace'`` with a visible note appended AFTER the result
|
||||
(trailing, so first-line failure classification is never masked),
|
||||
and writes (write_file/edit_text) return an actionable
|
||||
ROOT_REQUIRED_ACTIVE_WORKSPACE redirect instead of a generic access denial.
|
||||
The destination root still passes every downstream gate (profile access
|
||||
decision, protected-path guards, subagent filters) — only the label is
|
||||
corrected, never the authority. ``query_code`` is excluded: its
|
||||
root=user_files external-target contract handles absolute paths natively."""
|
||||
if name not in _PATH_NORMALIZED_TOOLS:
|
||||
return ""
|
||||
root_arg = str(args.get("root") or "active_workspace")
|
||||
if root_arg in ("active_workspace", "system_repo"):
|
||||
try:
|
||||
norm_root = active_repo_dir_for(ctx) if root_arg == "active_workspace" else system_repo_dir_for(ctx)
|
||||
for _key in ("path", "dir"):
|
||||
if isinstance(args.get(_key), str) and args[_key]:
|
||||
args[_key] = _registry().normalize_root_relative(norm_root, args[_key])
|
||||
if isinstance(args.get("files"), list):
|
||||
for _f in args["files"]:
|
||||
if isinstance(_f, dict) and isinstance(_f.get("path"), str) and _f["path"]:
|
||||
_f["path"] = _registry().normalize_root_relative(norm_root, _f["path"])
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
if root_arg != "user_files" or name == "query_code":
|
||||
return ""
|
||||
try:
|
||||
workspace = pathlib.Path(active_repo_dir_for(ctx)).resolve(strict=False)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _under_workspace(text: str) -> bool:
|
||||
if not _registry().is_absolute_path_text(text):
|
||||
return False
|
||||
try:
|
||||
pathlib.Path(text).expanduser().resolve(strict=False).relative_to(workspace)
|
||||
return True
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
candidates: list[str] = []
|
||||
for _key in ("path", "dir"):
|
||||
if isinstance(args.get(_key), str) and args[_key]:
|
||||
candidates.append(args[_key])
|
||||
if isinstance(args.get("files"), list):
|
||||
for _f in args["files"]:
|
||||
if isinstance(_f, dict) and isinstance(_f.get("path"), str) and _f["path"]:
|
||||
candidates.append(_f["path"])
|
||||
hits = [text for text in candidates if _under_workspace(text)]
|
||||
if not hits:
|
||||
return ""
|
||||
if name in ("write_file", "edit_text"):
|
||||
return (
|
||||
"⚠️ ROOT_REQUIRED_ACTIVE_WORKSPACE: absolute path "
|
||||
f"{hits[0]!r} is under the active workspace, but root='user_files' does not "
|
||||
"write there. Retry the same call with root='active_workspace' (the same "
|
||||
"path is accepted)."
|
||||
)
|
||||
args["root"] = "active_workspace"
|
||||
try:
|
||||
for _key in ("path", "dir"):
|
||||
if isinstance(args.get(_key), str) and args[_key]:
|
||||
args[_key] = _registry().normalize_root_relative(workspace, args[_key])
|
||||
if isinstance(args.get("files"), list):
|
||||
for _f in args["files"]:
|
||||
if isinstance(_f, dict) and isinstance(_f.get("path"), str) and _f["path"]:
|
||||
_f["path"] = _registry().normalize_root_relative(workspace, _f["path"])
|
||||
except Exception:
|
||||
pass
|
||||
return (
|
||||
"⚠️ AUTO_ROUTED_TO_ACTIVE_WORKSPACE: absolute path "
|
||||
f"{hits[0]!r} is under the active workspace; the call ran with "
|
||||
"root='active_workspace'. Pass root='active_workspace' directly for "
|
||||
"workspace paths."
|
||||
)
|
||||
|
||||
|
||||
_TOOL_ARG_ALIASES: dict[str, dict[str, str]] = {
|
||||
"*": {"max_entries": "max_results"},
|
||||
}
|
||||
|
||||
|
||||
_IGNORE_ROOT_ARG_TOOLS = frozenset({
|
||||
"commit_reviewed",
|
||||
"vcs_commit_reviewed",
|
||||
})
|
||||
|
||||
|
||||
_GENERIC_VCS_TARGET_TOOLS = frozenset({
|
||||
"vcs_status",
|
||||
"vcs_diff",
|
||||
"vcs_pull_ff",
|
||||
"vcs_restore",
|
||||
"vcs_revert",
|
||||
})
|
||||
|
||||
|
||||
_TARGET_BINDING_OPERATIONS = {
|
||||
"read_file": "read",
|
||||
"list_files": "list",
|
||||
"search_code": "search",
|
||||
"query_code": "search",
|
||||
"write_file": "write",
|
||||
"edit_text": "edit",
|
||||
"apply_patch": "edit",
|
||||
"edit_batch": "edit",
|
||||
**{name: "vcs" for name in _GENERIC_VCS_TARGET_TOOLS},
|
||||
}
|
||||
|
||||
|
||||
_SKILL_LIFECYCLE_TARGET_TOOLS = frozenset({
|
||||
"skill_review",
|
||||
"skill_preflight",
|
||||
"submit_skill_to_hub",
|
||||
})
|
||||
|
||||
|
||||
_PROCESS_TARGET_TOOLS = frozenset({"run_command", "run_script", "start_service"})
|
||||
|
||||
|
||||
_VERIFY_RUN_KINDS = frozenset({
|
||||
"visible_verifier",
|
||||
"explicit_command",
|
||||
"explicit_metric",
|
||||
})
|
||||
|
||||
|
||||
def _target_binding_operation(name: str, args: dict[str, Any]) -> str | None:
|
||||
operation = _TARGET_BINDING_OPERATIONS.get(name)
|
||||
if operation is not None:
|
||||
return operation
|
||||
if name in _SKILL_LIFECYCLE_TARGET_TOOLS:
|
||||
return "review"
|
||||
if name in _PROCESS_TARGET_TOOLS:
|
||||
return "service" if name == "start_service" else "shell"
|
||||
if name == "verify_and_record" and str(args.get("contract_kind") or "") in _VERIFY_RUN_KINDS:
|
||||
return "shell"
|
||||
# CONDITIONAL, never a static map entry (R1 item 1): delegate_start becomes
|
||||
# target-bound only when it explicitly selects an exact skill payload; a
|
||||
# plain or retry call keeps its current active-workspace behavior untouched.
|
||||
# ONLY the known selector value binds here — any other root value falls
|
||||
# through to the handler's TYPED unsupported_root refusal instead of an
|
||||
# untyped ValueError from binding construction (gate fix 9).
|
||||
if (name == "delegate_start"
|
||||
and str(args.get("root") or "").strip() == "skill_payload"
|
||||
and not str(args.get("retry_of") or "").strip()):
|
||||
return "write"
|
||||
return None
|
||||
|
||||
|
||||
def _handler_public_params(handler: Callable[..., Any]) -> list[str]:
|
||||
try:
|
||||
params = list(inspect.signature(handler).parameters)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return [name for name in params if name not in {"ctx", "_resolved_binding"}]
|
||||
|
||||
|
||||
def _entry_public_params(entry: "ToolEntry") -> list[str]:
|
||||
try:
|
||||
params = entry.schema.get("parameters") or {}
|
||||
props = params.get("properties")
|
||||
if isinstance(props, dict):
|
||||
return [str(name) for name in props]
|
||||
except Exception:
|
||||
pass
|
||||
return _handler_public_params(entry.handler)
|
||||
|
||||
|
||||
def _entry_has_public_param_schema(entry: "ToolEntry") -> bool:
|
||||
try:
|
||||
params = entry.schema.get("parameters") or {}
|
||||
return isinstance(params.get("properties"), dict)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_tool_call_args(entry: "ToolEntry", args: dict[str, Any]) -> None:
|
||||
tool_name = entry.name
|
||||
accepted = set(_entry_public_params(entry))
|
||||
aliases: dict[str, str] = {}
|
||||
aliases.update(_TOOL_ARG_ALIASES.get("*", {}))
|
||||
aliases.update(_TOOL_ARG_ALIASES.get(tool_name, {}))
|
||||
for alias, canonical in aliases.items():
|
||||
if alias in args and canonical in accepted and alias not in accepted and canonical not in args:
|
||||
args[canonical] = args.pop(alias)
|
||||
if tool_name in _IGNORE_ROOT_ARG_TOOLS and "root" in args and "root" not in accepted:
|
||||
args.pop("root", None)
|
||||
|
||||
|
||||
def _prepare_public_builtin_args(entry: "ToolEntry", args: dict[str, Any]) -> str:
|
||||
"""Normalize and validate only the model-visible builtin argument surface.
|
||||
|
||||
This runs after capability/lineage availability checks but before path
|
||||
normalization, target selection, Python predispatch, or target-sensitive
|
||||
guards. Private dispatch carriers therefore cannot be supplied by the model
|
||||
and invalid public calls cannot trigger target work before rejection.
|
||||
"""
|
||||
|
||||
_normalize_tool_call_args(entry, args)
|
||||
public_params = set(_entry_public_params(entry))
|
||||
# A handler may name a bounded set of execution-only legacy parameters. They
|
||||
# remain absent from its model-visible schema and are therefore usable only by
|
||||
# callers replaying the former wire shape through this real registry path. The
|
||||
# handler still owns deterministic migration/refusal; this generic seam neither
|
||||
# chooses a route nor special-cases a tool name.
|
||||
hidden_legacy = {
|
||||
str(name)
|
||||
for name in (getattr(entry.handler, "_hidden_legacy_params", ()) or ())
|
||||
if str(name)
|
||||
}
|
||||
accepted_params = public_params | hidden_legacy
|
||||
if _entry_has_public_param_schema(entry) and any(key not in accepted_params for key in args):
|
||||
return _format_tool_arg_error(entry)
|
||||
try:
|
||||
inspect.signature(entry.handler).bind(object(), **args)
|
||||
except TypeError:
|
||||
return _format_tool_arg_error(entry)
|
||||
return ""
|
||||
|
||||
|
||||
def _build_builtin_target_binding(ctx: Any, name: str, args: dict[str, Any]) -> Any:
|
||||
"""Build the one private physical-target carrier for a builtin call."""
|
||||
|
||||
operation = _target_binding_operation(name, args)
|
||||
if operation is None:
|
||||
return None
|
||||
if name in _SKILL_LIFECYCLE_TARGET_TOOLS:
|
||||
return _registry().build_resolved_resource_binding(
|
||||
ctx,
|
||||
root="skill_payload",
|
||||
operation="review",
|
||||
path=".",
|
||||
skill_name=str(args.get("skill") or ""),
|
||||
)
|
||||
if name in _PROCESS_TARGET_TOOLS or name == "verify_and_record":
|
||||
return _registry().build_resolved_resource_binding(
|
||||
ctx,
|
||||
operation=operation,
|
||||
process_cwd=str(args.get("cwd") or ""),
|
||||
bucket=str(args.get("bucket") or ""),
|
||||
skill_name=str(args.get("skill_name") or ""),
|
||||
)
|
||||
if name == "delegate_start":
|
||||
return _registry().build_resolved_resource_binding(
|
||||
ctx,
|
||||
root=str(args.get("root") or ""),
|
||||
operation="write",
|
||||
path=".",
|
||||
bucket=str(args.get("bucket") or ""),
|
||||
skill_name=str(args.get("skill_name") or ""),
|
||||
)
|
||||
root = str(args.get("root") or "active_workspace")
|
||||
bucket = str(args.get("bucket") or "")
|
||||
skill_name = str(args.get("skill_name") or "")
|
||||
|
||||
def _one(path: str) -> Any:
|
||||
return _registry().build_resolved_resource_binding(
|
||||
ctx,
|
||||
root=root,
|
||||
operation=operation,
|
||||
path=path or ".",
|
||||
bucket=bucket,
|
||||
skill_name=skill_name,
|
||||
)
|
||||
|
||||
if name == "write_file" and args.get("files"):
|
||||
return tuple(
|
||||
_one(str(item.get("path") or ""))
|
||||
for item in args.get("files") or []
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
if name == "apply_patch":
|
||||
from ouroboros.tools.edit_ops import patch_target_paths
|
||||
|
||||
return tuple(_one(path) for path in patch_target_paths(str(args.get("patch") or "")))
|
||||
if name == "edit_batch":
|
||||
return tuple(
|
||||
_one(str(item.get("path") or ""))
|
||||
for item in args.get("edits") or []
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return _one(str(args.get("path") or "."))
|
||||
|
||||
|
||||
def _binding_items(binding: Any) -> tuple[Any, ...]:
|
||||
if binding is None:
|
||||
return ()
|
||||
return binding if isinstance(binding, tuple) else (binding,)
|
||||
|
||||
|
||||
def _binding_set_targets_system_repo(ctx: Any, binding: Any) -> bool:
|
||||
items = _binding_items(binding)
|
||||
return bool(items) and all(_registry().binding_targets_system_repo(ctx, item) for item in items)
|
||||
|
||||
|
||||
def _binding_set_is_light_restricted(ctx: Any, binding: Any) -> bool:
|
||||
"""Whether light mode must treat this file/VCS target as internal state."""
|
||||
items = _binding_items(binding)
|
||||
return bool(items) and all(
|
||||
_registry().binding_targets_system_repo(ctx, item)
|
||||
or (item.root == "runtime_data" and item.source == "runtime_data")
|
||||
for item in items
|
||||
)
|
||||
|
||||
|
||||
def _binding_state_drive_root(ctx: Any, binding: Any) -> pathlib.Path:
|
||||
items = _binding_items(binding)
|
||||
if items:
|
||||
return pathlib.Path(items[0].state_drive_root)
|
||||
return pathlib.Path(ctx.drive_root)
|
||||
|
||||
|
||||
def _light_binding_failure_redirect(name: str, args: dict[str, Any]) -> str:
|
||||
"""Project an existing light-mode UX redirect after a failed target bind."""
|
||||
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
|
||||
if get_runtime_mode() == "light":
|
||||
return _registry().light_cognitive_or_root_redirect(name, args) or ""
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _binding_error_text(name: str, root: str, exc: Exception) -> str:
|
||||
detail = str(exc)
|
||||
if detail.startswith("SKILL_REDIRECT_BLOCKED:"):
|
||||
return f"⚠️ {detail}"
|
||||
if detail.startswith("profile=") and " cannot " in detail:
|
||||
return f"⚠️ TOOL_ACCESS_BLOCKED: {detail.rstrip('.')}."
|
||||
if isinstance(exc, _registry().UserFilesPathBlockedError) and name in {
|
||||
"read_file", "list_files", "search_code",
|
||||
}:
|
||||
return f"⚠️ USER_FILES_PATH_BLOCKED: {detail}"
|
||||
if root == "skill_payload" and name in {"write_file", "edit_text"}:
|
||||
return f"⚠️ SKILL_PAYLOAD_ARG_ERROR: {detail}"
|
||||
prefixes = {
|
||||
"read_file": "READ_FILE_ERROR",
|
||||
"list_files": "LIST_FILES_ERROR",
|
||||
"search_code": "SEARCH_ERROR",
|
||||
"query_code": "TOOL_ARG_ERROR (query_code)",
|
||||
"write_file": "WRITE_FILE_ERROR",
|
||||
"edit_text": "EDIT_TEXT_ERROR",
|
||||
"vcs_status": "GIT_ERROR",
|
||||
"vcs_diff": "GIT_ERROR",
|
||||
"vcs_pull_ff": "PULL_ERROR",
|
||||
"vcs_restore": "RESTORE_ERROR",
|
||||
"vcs_revert": "REVERT_ERROR",
|
||||
"skill_review": "SKILL_REVIEW_ERROR",
|
||||
"skill_preflight": "SKILL_PREFLIGHT_ERROR",
|
||||
"submit_skill_to_hub": "SUBMIT_BLOCKED",
|
||||
"run_command": "SHELL_CWD_BLOCKED",
|
||||
"run_script": "SCRIPT_CWD_BLOCKED",
|
||||
"start_service": "SHELL_CWD_BLOCKED",
|
||||
"verify_and_record": "VERIFY_ERROR",
|
||||
}
|
||||
return f"⚠️ {prefixes.get(name, 'TOOL_ERROR')}: {type(exc).__name__}: {detail}"
|
||||
|
||||
|
||||
def _format_tool_arg_error(entry: "ToolEntry") -> str:
|
||||
params = _entry_public_params(entry)
|
||||
accepted = ", ".join(params) if params else "none"
|
||||
return (
|
||||
f"⚠️ TOOL_ARG_ERROR ({entry.name}): invalid arguments for {entry.name}. "
|
||||
f"Accepted parameters: {accepted}."
|
||||
)
|
||||
|
|
@ -19,6 +19,14 @@ HOT_CODE_PATHS = frozenset({
|
|||
"ouroboros/size_ratchet_manifest.py",
|
||||
"ouroboros/tools/control.py",
|
||||
"ouroboros/tools/registry.py",
|
||||
# v7 D04 split leaves: code that merely moved out of the hot registry
|
||||
# keeps the label (parity rule pinned by tests/test_lc2_owner_facades.py
|
||||
# for the inverse direction).
|
||||
"ouroboros/tools/registry_guard_process.py",
|
||||
"ouroboros/tools/registry_guards.py",
|
||||
"ouroboros/tools/tool_catalog.py",
|
||||
"ouroboros/tools/tool_context.py",
|
||||
"ouroboros/tools/tool_resolution.py",
|
||||
"ouroboros/config.py",
|
||||
"supervisor/queue.py",
|
||||
"supervisor/events.py",
|
||||
|
|
|
|||
160
tests/test_tool_access_extraction.py
Normal file
160
tests/test_tool_access_extraction.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Structural contracts for the semantic-no-op tool_access extraction.
|
||||
|
||||
Carried from the v7 reference (ouroboros_v7_wip @ 9f691656) with four disclosed
|
||||
adaptations to THIS tree:
|
||||
|
||||
1. The frozen-tool-inventory clause is dropped: ``ouroboros.tool_module_inventory``
|
||||
is a v7 leaf this tree does not carry yet; the clause returns with that leaf.
|
||||
2. The no-backedge clause asserts no MODULE-LEVEL (import-time) import of the
|
||||
facade: on this tree the leaves deliberately read parent-owned rebindable
|
||||
names through a call-time module handle (the owner-approved D18/D33
|
||||
mechanical exception), which is not an import-time cycle.
|
||||
3. The one-matrix clause checks identity through the facade re-export only:
|
||||
the user_files leaf reads ``_POLICY`` through the call-time handle instead
|
||||
of binding a module attribute, so the same-object guarantee holds by
|
||||
construction (there is exactly one binding, on the facade).
|
||||
4. The facade size bound is kept at the reference's 900; this tree's facade is
|
||||
the tip monolith minus the moved spans and lands under it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
from ouroboros import (
|
||||
tool_access,
|
||||
tool_access_paths,
|
||||
tool_access_roots,
|
||||
tool_access_types,
|
||||
tool_access_user_files,
|
||||
)
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parents[1]
|
||||
|
||||
_LEAVES = (
|
||||
tool_access_types,
|
||||
tool_access_paths,
|
||||
tool_access_roots,
|
||||
tool_access_user_files,
|
||||
)
|
||||
|
||||
_MOVED_OWNERS = {
|
||||
"Operation": tool_access_types,
|
||||
"ResolvedResourceBinding": tool_access_types,
|
||||
"ResourceRoot": tool_access_types,
|
||||
"SUBAGENT_CAPABILITIES": tool_access_types,
|
||||
"SubagentCapability": tool_access_types,
|
||||
"ToolAccessDecision": tool_access_types,
|
||||
"ToolProfile": tool_access_types,
|
||||
"_ALL_ROOTS": tool_access_types,
|
||||
"_POLICY": tool_access_types,
|
||||
"_READONLY_RESOURCE_ROOTS": tool_access_types,
|
||||
"_READ_OPS": tool_access_types,
|
||||
"_SUBAGENT_CAPABILITY_TO_OPERATION": tool_access_types,
|
||||
"_TOP_LEVEL_PRINCIPAL_POLICY": tool_access_types,
|
||||
"_TOP_LEVEL_PRINCIPAL_PROFILES": tool_access_types,
|
||||
"_deliverables_root": tool_access_paths,
|
||||
"_path_is_relative_to_casefold": tool_access_paths,
|
||||
"_user_files_root": tool_access_paths,
|
||||
"canonical_data_root": tool_access_paths,
|
||||
"normalize_root": tool_access_paths,
|
||||
"normalize_root_relative": tool_access_paths,
|
||||
"normalize_runtime_data_path": tool_access_paths,
|
||||
"path_is_relative_to": tool_access_paths,
|
||||
"paths_overlap_casefold": tool_access_paths,
|
||||
"workspace_mode_block_reason": tool_access_paths,
|
||||
"_is_subagent_ctx": tool_access_roots,
|
||||
"_skill_payload_base": tool_access_roots,
|
||||
"active_tool_profile": tool_access_roots,
|
||||
"binding_targets_system_repo": tool_access_roots,
|
||||
"is_external_workspace": tool_access_roots,
|
||||
"load_bound_skill": tool_access_roots,
|
||||
"predicted_subagent_profile": tool_access_roots,
|
||||
"project_room_lens_dir": tool_access_roots,
|
||||
"resource_root_path": tool_access_roots,
|
||||
"UserFilesPathBlockedError": tool_access_user_files,
|
||||
"_USER_FILES_ALLOWED_DOTNAMES": tool_access_user_files,
|
||||
"_USER_FILES_SECRET_COMPONENTS": tool_access_user_files,
|
||||
"_USER_FILES_SECRET_NAMES": tool_access_user_files,
|
||||
"_USER_FILES_SECRET_RE": tool_access_user_files,
|
||||
"_subagent_projects_read_hint": tool_access_user_files,
|
||||
"resolve_user_file_path": tool_access_user_files,
|
||||
"user_files_path_block_reason": tool_access_user_files,
|
||||
}
|
||||
|
||||
|
||||
def test_tool_access_leaves_are_non_catalog_owners_without_import_backedges():
|
||||
for module in (tool_access, *_LEAVES):
|
||||
source_path = pathlib.Path(module.__file__)
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"))
|
||||
assert not any(
|
||||
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "get_tools"
|
||||
for node in tree.body
|
||||
)
|
||||
for module in _LEAVES:
|
||||
tree = ast.parse(pathlib.Path(module.__file__).read_text(encoding="utf-8"))
|
||||
# No import-time backedge: the facade may only be read through the
|
||||
# call-time module handle inside function bodies (D18/D33 idiom).
|
||||
for node in tree.body:
|
||||
assert not (
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module in ("ouroboros.tool_access", "ouroboros")
|
||||
and any(a.name == "tool_access" or node.module == "ouroboros.tool_access"
|
||||
for a in node.names)
|
||||
), f"{module.__name__} imports the facade at module level"
|
||||
assert not (
|
||||
isinstance(node, ast.Import)
|
||||
and any(a.name == "ouroboros.tool_access" for a in node.names)
|
||||
), f"{module.__name__} imports the facade at module level"
|
||||
|
||||
|
||||
def test_tool_access_decision_surface_stays_with_the_matrix_owner():
|
||||
"""The access decision, its affordance projections, and the binding builder
|
||||
remain authored by ``tool_access`` itself; the leaves own vocabulary,
|
||||
physical paths, root resolution, and one root's path policy."""
|
||||
for name in (
|
||||
"decide_tool_access",
|
||||
"subagent_profile_satisfies",
|
||||
"summarize_subagent_profile",
|
||||
"filesystem_affordance_map",
|
||||
"profile_readable_root_paths",
|
||||
"shell_cwd_block_message",
|
||||
"resolve_shell_cwd",
|
||||
"build_resolved_resource_binding",
|
||||
"resolve_resource_path",
|
||||
):
|
||||
assert getattr(tool_access, name).__module__ == "ouroboros.tool_access", name
|
||||
|
||||
|
||||
def test_tool_access_facade_reexports_every_moved_identity():
|
||||
"""``tool_access`` keeps the exact objects, so the registry, the tool
|
||||
handlers, the supervisor and every guard that imports these names see no
|
||||
identity change."""
|
||||
for name, owner in _MOVED_OWNERS.items():
|
||||
assert hasattr(tool_access, name), name
|
||||
assert getattr(tool_access, name) is getattr(owner, name), name
|
||||
owned = {name for module in _LEAVES for name in vars(module)}
|
||||
assert set(_MOVED_OWNERS) <= owned
|
||||
|
||||
|
||||
def test_tool_access_policy_matrix_is_one_object_across_owners():
|
||||
"""Every reader of the matrix must observe the same mapping object, not a
|
||||
copy. The user_files leaf reads it through the call-time facade handle, so
|
||||
the facade re-export IS its binding."""
|
||||
assert tool_access._POLICY is tool_access_types._POLICY
|
||||
assert set(tool_access._ALL_ROOTS) == set(tool_access_types._ALL_ROOTS)
|
||||
|
||||
|
||||
def test_tool_access_extraction_size_bounds_have_meaningful_headroom():
|
||||
counts = {
|
||||
module.__name__: len(
|
||||
pathlib.Path(module.__file__).read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
for module in (tool_access, *_LEAVES)
|
||||
}
|
||||
assert counts["ouroboros.tool_access"] <= 900
|
||||
assert all(count <= 1000 for count in counts.values())
|
||||
assert 200 <= counts["ouroboros.tool_access_user_files"] <= 1000
|
||||
File diff suppressed because it is too large
Load diff
468
tests/test_tool_capabilities_black_box_policy.py
Normal file
468
tests/test_tool_capabilities_black_box_policy.py
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
"""The protected black-box policy over executor artifacts and control state.
|
||||
|
||||
Split verbatim out of ``tests/test_tool_capabilities.py`` by theme. This
|
||||
module owns the introspection fence: which paths the black-box policy
|
||||
blocks, how it maps executor backend paths recursively, and the runtime
|
||||
data-write block on workspace executor control state.
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
def test_protected_black_box_artifact_policy_blocks_introspection(tmp_path, monkeypatch):
|
||||
from ouroboros.contracts.task_contract import build_task_contract
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
data.mkdir()
|
||||
if os.name == "nt":
|
||||
protected = repo / "reference.cmd"
|
||||
generated = repo / "generated.cmd"
|
||||
direct_cmd = ["cmd.exe", "/c", str(protected)]
|
||||
protected.write_text("@echo reference\r\n", encoding="utf-8")
|
||||
generated.write_text("@echo generated\r\n", encoding="utf-8")
|
||||
else:
|
||||
protected = repo / "reference.sh"
|
||||
generated = repo / "generated.sh"
|
||||
direct_cmd = [str(protected)]
|
||||
protected.write_text("#!/bin/sh\nprintf 'reference\\n'\n", encoding="utf-8")
|
||||
generated.write_text("#!/bin/sh\nprintf 'generated\\n'\n", encoding="utf-8")
|
||||
protected_dir = repo / "protected_dir"
|
||||
protected_dir.mkdir()
|
||||
(protected_dir / "secret.txt").write_text("secret\n", encoding="utf-8")
|
||||
protected.chmod(0o755)
|
||||
generated.chmod(0o755)
|
||||
task_contract = build_task_contract({
|
||||
"resource_policy": {
|
||||
"protected_artifacts": [
|
||||
{
|
||||
"id": "reference",
|
||||
"role": "black_box_reference",
|
||||
"paths": [str(protected)],
|
||||
"allow": ["execute"],
|
||||
"deny": ["read_bytes", "copy", "hash", "static_introspection", "dynamic_trace", "debug"],
|
||||
},
|
||||
{
|
||||
"id": "reference-dir",
|
||||
"role": "black_box_reference",
|
||||
"paths": [str(protected_dir)],
|
||||
"allow": ["execute"],
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
registry.set_context(ToolContext(
|
||||
repo_dir=repo,
|
||||
drive_root=data,
|
||||
task_contract=task_contract,
|
||||
task_metadata={"task_contract": task_contract},
|
||||
))
|
||||
monkeypatch.setattr("ouroboros.safety.check_safety", lambda *a, **k: (True, ""))
|
||||
|
||||
direct = registry.execute("run_command", {"cmd": direct_cmd})
|
||||
assert "RESOURCE_POLICY_BLOCKED" not in direct
|
||||
assert "reference" in direct
|
||||
assert "RESOURCE_POLICY_BLOCKED" in registry.execute("read_file", {"path": protected.name})
|
||||
protected_content = protected.read_text(encoding="utf-8")
|
||||
write_attempt = registry.execute("write_file", {"path": protected.name, "content": "tamper\n"})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in write_attempt
|
||||
assert protected.read_text(encoding="utf-8") == protected_content
|
||||
edit_attempt = registry.execute("edit_text", {"path": protected.name, "old_str": "reference", "new_str": "tamper"})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in edit_attempt
|
||||
assert protected.read_text(encoding="utf-8") == protected_content
|
||||
shell_write_attempt = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["sh", "-c", f"printf tamper > {protected.name}"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in shell_write_attempt
|
||||
assert protected.read_text(encoding="utf-8") == protected_content
|
||||
shell_delete_attempt = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["rm", protected.name], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in shell_delete_attempt
|
||||
assert protected.exists()
|
||||
recursive_delete_attempt = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["rm", "-rf", "."], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in recursive_delete_attempt
|
||||
assert protected.exists()
|
||||
glob_delete_attempt = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["sh", "-c", "rm -rf *"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in glob_delete_attempt
|
||||
assert protected.exists()
|
||||
glob_read_attempt = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["sh", "-c", "cat *"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in glob_read_attempt
|
||||
find_exec_read = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["find", ".", "-type", "f", "-exec", "cat", "{}", "+"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in find_exec_read
|
||||
find_delete = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["find", ".", "-delete"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in find_delete
|
||||
assert protected.exists()
|
||||
pathless_find_exec_read = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["find", "-type", "f", "-exec", "cat", "{}", "+"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in pathless_find_exec_read
|
||||
pathless_find_delete = registry.execute(
|
||||
"run_command",
|
||||
{"cmd": ["find", "-delete"], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in pathless_find_delete
|
||||
assert protected.exists()
|
||||
safe_interpreter = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"print(1)",
|
||||
],
|
||||
"cwd": str(repo),
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" not in safe_interpreter
|
||||
assert "1" in safe_interpreter
|
||||
assert "RESOURCE_POLICY_BLOCKED" in registry.execute("list_files", {"path": protected_dir.name})
|
||||
interpreter_read = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
sys.executable,
|
||||
"-c",
|
||||
f"from pathlib import Path; print(Path(r'{protected}').read_bytes())",
|
||||
]
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in interpreter_read
|
||||
relative_interpreter_read = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
sys.executable,
|
||||
"-c",
|
||||
f"from pathlib import Path; print(Path({protected.name!r}).read_bytes())",
|
||||
],
|
||||
"cwd": str(repo),
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in relative_interpreter_read
|
||||
versioned_interpreter_read = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
"python3.12",
|
||||
"-c",
|
||||
f"from pathlib import Path; print(Path({protected.name!r}).read_bytes())",
|
||||
],
|
||||
"cwd": str(repo),
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in versioned_interpreter_read
|
||||
constructed_path_read = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; "
|
||||
f"print((Path(r'{protected.parent}') / ({protected.stem!r} + {protected.suffix!r})).read_bytes())"
|
||||
),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in constructed_path_read
|
||||
backslash_parent = str(protected.parent).replace("/", "\\")
|
||||
backslash_constructed_path_read = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; "
|
||||
f"print((Path({backslash_parent!r}) / ({protected.stem!r} + {protected.suffix!r})).read_bytes())"
|
||||
),
|
||||
]
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in backslash_constructed_path_read
|
||||
env_assignment_read = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
f"REF={protected}",
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import os; print(open(os.environ['REF'], 'rb').read())",
|
||||
]
|
||||
},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in env_assignment_read
|
||||
shell_script_read = registry.execute("run_command", {"cmd": ["sh", str(protected)]})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in shell_script_read
|
||||
for cmd in (
|
||||
["cmd.exe", "/c", "type", protected.name],
|
||||
["cmd.exe", "/c", "copy", protected.name, str(repo / "copy.cmd")],
|
||||
["cmd.exe", "/c", "xcopy", protected.name, str(repo / "copy-dir")],
|
||||
["powershell.exe", "-Command", "Get-Content", protected.name],
|
||||
["powershell.exe", "-Command", "Select-String", "reference", protected.name],
|
||||
["powershell.exe", "-Command", "Copy-Item", protected.name, str(repo / "copy.ps1")],
|
||||
["pwsh", "-Command", "Get-FileHash", protected.name],
|
||||
["cmd.exe", "/c", "certutil", "-hashfile", protected.name],
|
||||
):
|
||||
result = registry.execute("run_command", {"cmd": cmd, "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in result, cmd
|
||||
encoded_read = base64.b64encode(f"Get-Content {protected.name}".encode("utf-16le")).decode("ascii")
|
||||
for cmd in (
|
||||
["powershell.exe", "-EncodedCommand", encoded_read],
|
||||
["pwsh", "-enc", encoded_read],
|
||||
):
|
||||
result = registry.execute("run_command", {"cmd": cmd, "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in result, cmd
|
||||
search_direct = registry.execute("search_code", {"query": "reference", "path": protected.name})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in search_direct
|
||||
search_protected_dir = registry.execute("search_code", {"query": "secret", "path": protected_dir.name})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in search_protected_dir
|
||||
query_protected = registry.execute("query_code", {"op": "structural", "query": "reference", "path": protected.name})
|
||||
assert "RESOURCE_POLICY_BLOCKED" not in query_protected
|
||||
assert protected.name not in query_protected
|
||||
for cache in (data / "state" / "code_intel").glob("*/inventory.json"):
|
||||
assert protected.name not in cache.read_text(encoding="utf-8")
|
||||
grep_read = registry.execute("run_command", {"cmd": ["grep", "reference", str(protected)]})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in grep_read
|
||||
grep_recursive = registry.execute("run_command", {"cmd": ["grep", "-R", "reference", "."], "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in grep_recursive
|
||||
rg_read = registry.execute("run_command", {"cmd": ["rg", "reference", str(protected)]})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in rg_read
|
||||
rg_recursive = registry.execute("run_command", {"cmd": ["rg", "reference", "."], "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in rg_recursive
|
||||
copy_recursive = registry.execute("run_command", {"cmd": ["cp", "-R", ".", str(repo / "copy")], "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in copy_recursive
|
||||
for cmd in (
|
||||
["git", "diff", "--", protected.name],
|
||||
["git", "diff"],
|
||||
["git", "show", f"HEAD:{protected.name}"],
|
||||
["git", "show", "HEAD"],
|
||||
["git", "grep", "reference", "--", protected.name],
|
||||
["git", "grep", "reference"],
|
||||
["git", "cat-file", "-p", f"HEAD:{protected.name}"],
|
||||
["git", "log", "-p", "--", protected.name],
|
||||
["git", "log", "-p"],
|
||||
):
|
||||
result = registry.execute("run_command", {"cmd": cmd, "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in result, cmd
|
||||
assert "RESOURCE_POLICY_BLOCKED" in registry.execute("vcs_diff", {"path": protected.name})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in registry.execute("vcs_diff", {})
|
||||
import ouroboros.code_intelligence as code_intelligence
|
||||
|
||||
original_file_fact = code_intelligence._file_fact
|
||||
|
||||
def guarded_file_fact(repo_root, path):
|
||||
assert pathlib.Path(path).resolve(strict=False) != protected.resolve(strict=False)
|
||||
return original_file_fact(repo_root, path)
|
||||
|
||||
monkeypatch.setattr(code_intelligence, "_file_fact", guarded_file_fact)
|
||||
digest = registry.execute("query_code", {"op": "digest"})
|
||||
assert protected.name not in digest
|
||||
assert generated.name in digest
|
||||
run_output_export = registry.execute("run_command", {"cmd": direct_cmd, "outputs": [protected.name], "cwd": str(repo)})
|
||||
assert "ARTIFACT_OUTPUT_ERROR" in run_output_export
|
||||
assert "RESOURCE_POLICY_BLOCKED" in run_output_export
|
||||
script_output_export = registry.execute(
|
||||
"run_script",
|
||||
{"interpreter": "python3", "script": "print('ok')", "outputs": [protected.name], "cwd": str(repo)},
|
||||
)
|
||||
assert "RESOURCE_POLICY_BLOCKED" in script_output_export
|
||||
service_cmd = ["cmd.exe", "/c", "ping", "127.0.0.1", "-n", "30"] if os.name == "nt" else ["sleep", "30"]
|
||||
service_start = registry.execute(
|
||||
"start_service",
|
||||
{
|
||||
"name": "protected-output",
|
||||
"cmd": service_cmd,
|
||||
"cwd": str(repo),
|
||||
"outputs": [protected.name],
|
||||
},
|
||||
)
|
||||
assert "protected-output" in service_start
|
||||
service_stop = registry.execute("stop_service", {"name": "protected-output"})
|
||||
assert "ARTIFACT_OUTPUT_ERROR" in service_stop
|
||||
assert "RESOURCE_POLICY_BLOCKED" in service_stop
|
||||
for cmd in (
|
||||
["strings", str(protected)],
|
||||
["objdump", "-d", str(protected)],
|
||||
["cat", str(protected)],
|
||||
["sha256sum", str(protected)],
|
||||
["strace", str(protected)],
|
||||
["gdb", str(protected)],
|
||||
["lldb", str(protected)],
|
||||
["cp", str(protected), str(repo / "copy.sh")],
|
||||
["dd", f"if={protected}", f"of={repo / 'copy2.sh'}"],
|
||||
["tar", "-czf", str(repo / "out.tgz"), protected.name],
|
||||
["tar", "-czf", str(repo / "tree.tgz"), "."],
|
||||
["zip", str(repo / "out.zip"), protected.name],
|
||||
["rsync", protected.name, str(repo / "copy.sh")],
|
||||
):
|
||||
result = registry.execute("run_command", {"cmd": cmd, "cwd": str(repo)})
|
||||
assert "RESOURCE_POLICY_BLOCKED" in result, cmd
|
||||
|
||||
generated_result = registry.execute("run_command", {"cmd": ["strings", str(generated)]})
|
||||
assert "RESOURCE_POLICY_BLOCKED" not in generated_result
|
||||
|
||||
|
||||
def test_protected_black_box_recursive_policy_maps_executor_backend_paths(tmp_path, monkeypatch):
|
||||
from ouroboros.contracts.task_contract import build_task_contract
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
system_repo = tmp_path / "system"
|
||||
workspace = tmp_path / "workspace"
|
||||
data = tmp_path / "data"
|
||||
for path in (system_repo, workspace, data):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
protected = workspace / "executable"
|
||||
protected.write_text("reference bytes\n", encoding="utf-8")
|
||||
task_contract = build_task_contract({
|
||||
"resource_policy": {
|
||||
"protected_artifacts": [
|
||||
{
|
||||
"id": "reference",
|
||||
"role": "black_box_reference",
|
||||
"paths": ["/workspace/executable"],
|
||||
"allow": ["execute"],
|
||||
"deny": ["read_bytes", "copy", "hash", "static_introspection", "dynamic_trace", "debug"],
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
registry = ToolRegistry(repo_dir=system_repo, drive_root=data)
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=system_repo,
|
||||
drive_root=data,
|
||||
workspace_root=workspace,
|
||||
workspace_mode="external",
|
||||
task_contract=task_contract,
|
||||
task_metadata={"task_contract": task_contract},
|
||||
executor_ref={
|
||||
"type": "docker_exec",
|
||||
"id": "pb-container",
|
||||
"container_name": "pb-container",
|
||||
"network": "none",
|
||||
"workspace_host_path": str(workspace),
|
||||
"workspace_backend_path": "/workspace",
|
||||
},
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("ouroboros.safety.check_safety", lambda *a, **k: (True, ""))
|
||||
|
||||
grep_recursive = registry.execute("run_command", {"cmd": ["grep", "-R", "reference", "."], "cwd": str(workspace)})
|
||||
copy_recursive = registry.execute("run_command", {"cmd": ["cp", "-R", ".", str(workspace / "copy")], "cwd": str(workspace)})
|
||||
import ouroboros.code_intelligence as code_intelligence
|
||||
|
||||
original_file_fact = code_intelligence._file_fact
|
||||
|
||||
def guarded_file_fact(repo_root, path):
|
||||
assert pathlib.Path(path).resolve(strict=False) != protected.resolve(strict=False)
|
||||
return original_file_fact(repo_root, path)
|
||||
|
||||
monkeypatch.setattr(code_intelligence, "_file_fact", guarded_file_fact)
|
||||
digest = registry.execute("query_code", {"op": "digest"})
|
||||
|
||||
assert "RESOURCE_POLICY_BLOCKED" in grep_recursive
|
||||
assert "RESOURCE_POLICY_BLOCKED" in copy_recursive
|
||||
assert "executable" not in digest
|
||||
|
||||
|
||||
def test_runtime_data_write_blocks_workspace_executor_control_state(tmp_path, monkeypatch):
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
data.mkdir()
|
||||
state_dir = data / "state" / "workspace_executor_processes"
|
||||
state_dir.mkdir(parents=True)
|
||||
existing = state_dir / "foreground-forged.json"
|
||||
existing.write_text("original", encoding="utf-8")
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
registry.set_context(ToolContext(repo_dir=repo, drive_root=data))
|
||||
monkeypatch.setattr("ouroboros.safety.check_safety", lambda *a, **k: (True, ""))
|
||||
|
||||
direct_write = registry.execute(
|
||||
"write_file",
|
||||
{
|
||||
"root": "runtime_data",
|
||||
"path": "state/workspace_executor_processes/foreground-forged.json",
|
||||
"content": "{}",
|
||||
},
|
||||
)
|
||||
assert "DATA_WRITE_BLOCKED" in direct_write
|
||||
assert existing.read_text(encoding="utf-8") == "original"
|
||||
|
||||
nested_write = registry.execute(
|
||||
"write_file",
|
||||
{
|
||||
"root": "runtime_data",
|
||||
"path": "state/headless_tasks/child/data/state/workspace_executor_processes/foreground-forged.json",
|
||||
"content": "{}",
|
||||
},
|
||||
)
|
||||
assert "DATA_WRITE_BLOCKED" in nested_write
|
||||
|
||||
edit = registry.execute(
|
||||
"edit_text",
|
||||
{
|
||||
"root": "runtime_data",
|
||||
"path": "state/workspace_executor_processes/foreground-forged.json",
|
||||
"old_str": "original",
|
||||
"new_str": "tampered",
|
||||
},
|
||||
)
|
||||
assert "EDIT_TEXT_BLOCKED" in edit
|
||||
assert existing.read_text(encoding="utf-8") == "original"
|
||||
|
||||
shell_write = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; "
|
||||
f"Path(r'{existing}').write_text('{{\"owner\":\"ouroboros_workspace_executor\"}}')"
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
assert "WORKSPACE_EXECUTOR_STATE_WRITE_BLOCKED" in shell_write
|
||||
assert existing.read_text(encoding="utf-8") == "original"
|
||||
|
||||
node_eval_write = registry.execute(
|
||||
"run_command",
|
||||
{
|
||||
"cmd": [
|
||||
"node",
|
||||
"-e",
|
||||
f"require('fs').writeFileSync({str(existing)!r}, '{{}}')",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert "WORKSPACE_EXECUTOR_STATE_WRITE_BLOCKED" in node_eval_write
|
||||
assert existing.read_text(encoding="utf-8") == "original"
|
||||
352
tests/test_tool_capabilities_readonly_subagent.py
Normal file
352
tests/test_tool_capabilities_readonly_subagent.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
"""What a local read-only subagent may reach.
|
||||
|
||||
Split verbatim out of ``tests/test_tool_capabilities.py`` by theme. This
|
||||
module owns the read-only subagent profile boundary: forbidden tools at
|
||||
execute time, the enabled extension tool it may still call, the
|
||||
allowed-resources block on web/external tools, and the secret-file,
|
||||
task-drive and skill-payload filters on its data and repo reads.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
|
||||
def test_local_readonly_subagent_execute_blocks_forbidden_tools(tmp_path, monkeypatch):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
import ouroboros.mcp_client as mcp_client
|
||||
|
||||
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=tmp_path,
|
||||
drive_root=tmp_path,
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert registry.get_schema_by_name("write_file") is None
|
||||
assert registry.get_schema_by_name("enable_tools") is None
|
||||
assert registry.get_schema_by_name("schedule_subagent") is not None
|
||||
# switch_model changes COGNITIVE POWER, not authority: a child that started cheap and
|
||||
# found the work harder raises its own strength, and nothing about its sandbox moves.
|
||||
# It was on the blocked list until v6.87.7 purely because power and authority were
|
||||
# conflated; a read-only child stays read-only at any model.
|
||||
assert registry.get_schema_by_name("switch_model") is not None
|
||||
assert "LOCAL_READONLY_SUBAGENT_BLOCKED" not in registry.execute("switch_model", {})
|
||||
monkeypatch.setattr(mcp_client, "ensure_configured_from_settings", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("MCP touched")))
|
||||
assert "LOCAL_READONLY_SUBAGENT_BLOCKED" not in registry.execute("list_files", {"path": "."})
|
||||
assert registry.get_schema_by_name("vcs_status") is not None
|
||||
assert "TOOL_ACCESS_BLOCKED" not in registry.execute("vcs_status", {"root": "system_repo"})
|
||||
blocked_tools = [
|
||||
"write_file",
|
||||
"edit_text",
|
||||
"knowledge_write",
|
||||
"update_scratchpad",
|
||||
"update_identity",
|
||||
"commit_reviewed",
|
||||
"preflight_review",
|
||||
"task_acceptance_review",
|
||||
"skill_review",
|
||||
"request_restart",
|
||||
"enable_tools",
|
||||
"run_command",
|
||||
"skill_exec",
|
||||
"list_skills",
|
||||
]
|
||||
for name in blocked_tools:
|
||||
assert registry.get_schema_by_name(name) is None
|
||||
assert "LOCAL_READONLY_SUBAGENT_BLOCKED" in registry.execute(name, {})
|
||||
|
||||
|
||||
def test_local_readonly_subagent_allows_enabled_extension_tool(tmp_path, monkeypatch):
|
||||
from ouroboros import extension_loader
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
from tests._shared import clean_extension_runtime_state
|
||||
from tests.test_extension_loader import _mark_isolated_deps_installed, _prepare_extension
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
clean_extension_runtime_state()
|
||||
plugin = (
|
||||
"def _lookup(ctx, query=''):\n"
|
||||
" return 'external-ok:' + query\n"
|
||||
"def register(api):\n"
|
||||
" api.register_tool('lookup', _lookup, description='External lookup', "
|
||||
"schema={'type': 'object', 'properties': {'query': {'type': 'string'}}}, timeout_sec=5)\n"
|
||||
)
|
||||
loaded, skills_repo, parent_drive = _prepare_extension(
|
||||
tmp_path,
|
||||
"research",
|
||||
plugin,
|
||||
permissions=["tool"],
|
||||
extra_frontmatter="dependencies:\n - dummy_pkg\n",
|
||||
)
|
||||
_mark_isolated_deps_installed(parent_drive, loaded)
|
||||
child_drive = tmp_path / "child-drive"
|
||||
child_drive.mkdir()
|
||||
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=parent_drive)
|
||||
assert err is None, err
|
||||
tool_name = extension_loader.extension_surface_name("research", "lookup")
|
||||
assert extension_loader.is_extension_live("research", parent_drive, repo_path=str(skills_repo))
|
||||
assert not extension_loader.is_extension_live("research", child_drive, repo_path=str(skills_repo))
|
||||
assert extension_loader.get_tool(tool_name)["out_of_process"] is True
|
||||
repo_dir = pathlib.Path(__file__).resolve().parents[1]
|
||||
registry = ToolRegistry(repo_dir=repo_dir, drive_root=child_drive)
|
||||
try:
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=repo_dir,
|
||||
drive_root=child_drive,
|
||||
task_metadata={"budget_drive_root": str(parent_drive)},
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
||||
)
|
||||
)
|
||||
assert registry.get_schema_by_name(tool_name) is not None
|
||||
assert "external-ok:budget-root" in registry.execute(tool_name, {"query": "budget-root"})
|
||||
finally:
|
||||
clean_extension_runtime_state()
|
||||
|
||||
|
||||
def test_allowed_resources_block_web_and_external_tools(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
|
||||
from ouroboros import extension_loader
|
||||
from ouroboros.contracts.task_contract import build_task_contract
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
task_contract = build_task_contract({
|
||||
"id": "task-resources",
|
||||
"allowed_resources": {"web": "false", "network": "false"},
|
||||
})
|
||||
tool_name = extension_loader.extension_surface_name("research", "lookup")
|
||||
with extension_loader._lock:
|
||||
extension_loader._tools[tool_name] = {
|
||||
"name": tool_name,
|
||||
"handler": lambda ctx, **kwargs: "external-ok",
|
||||
"description": "External lookup",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"timeout_sec": 5,
|
||||
"skill": "research",
|
||||
}
|
||||
monkeypatch.setattr(extension_loader, "is_extension_live", lambda *_a, **_k: True)
|
||||
try:
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=tmp_path / "repo",
|
||||
drive_root=tmp_path / "data",
|
||||
task_contract=task_contract,
|
||||
task_metadata={"task_contract": task_contract},
|
||||
)
|
||||
)
|
||||
assert task_contract["allowed_resources"] == {"web": False, "network": False}
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in registry.execute("web_search", {"query": "x"})
|
||||
# VLM tools are first-class vision tools, not web egress. Benchmark isolation
|
||||
# withholds them by name via disabled_tools instead of relying on web=false.
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" not in registry.execute("vlm_query", {"prompt": "x"})
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in registry.execute(
|
||||
"vlm_query", {"prompt": "x", "image_url": "https://example.com/a.png"}
|
||||
)
|
||||
assert registry.get_schema_by_name(tool_name) is None
|
||||
assert tool_name not in {schema["function"]["name"] for schema in registry.schemas()}
|
||||
assert any(item.get("surface") == "extensions" and item.get("reason") == "resource_blocked" for item in registry.capability_omissions())
|
||||
blocked = registry.execute(tool_name, {})
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in blocked
|
||||
assert "network=false" in blocked
|
||||
|
||||
alias_contract = build_task_contract({
|
||||
"id": "task-resource-aliases",
|
||||
"allowed_resources": {"allow_network": "false"},
|
||||
})
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=tmp_path / "repo",
|
||||
drive_root=tmp_path / "data",
|
||||
task_contract=alias_contract,
|
||||
task_metadata={"task_contract": alias_contract},
|
||||
)
|
||||
)
|
||||
assert alias_contract["allowed_resources"] == {"allow_network": False}
|
||||
assert "RESOURCE_CONSTRAINT_BLOCKED" in registry.execute("web_search", {"query": "x"})
|
||||
finally:
|
||||
with extension_loader._lock:
|
||||
extension_loader._tools.pop(tool_name, None)
|
||||
|
||||
|
||||
def test_local_readonly_subagent_data_read_denies_secret_files(tmp_path):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
(tmp_path / "settings.json").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
||||
(tmp_path / "settings.tmp").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
||||
(tmp_path / ".settings.json.tmp.123").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
||||
(tmp_path / ".env.local").write_text("TOKEN=secret", encoding="utf-8")
|
||||
(tmp_path / "prod.env").write_text("TOKEN=secret", encoding="utf-8")
|
||||
(tmp_path / "state" / "skills" / "weather").mkdir(parents=True)
|
||||
(tmp_path / "state" / "skills" / "weather" / "grants.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "state" / "skills" / "weather" / ".grants.json.tmp.123").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "state" / "skills" / "weather" / "review.json.lock").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "logs").mkdir()
|
||||
(tmp_path / "logs" / "events.jsonl").write_text("{}", encoding="utf-8")
|
||||
try:
|
||||
os.symlink("settings.json", tmp_path / "alias.txt")
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
try:
|
||||
os.link(tmp_path / "settings.json", tmp_path / "hardlink.txt")
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=tmp_path,
|
||||
drive_root=tmp_path,
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
||||
)
|
||||
)
|
||||
|
||||
blocked = registry.execute("read_file", {"root": "runtime_data", "path": "settings.json"})
|
||||
assert "DATA_READ_BLOCKED" in blocked
|
||||
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "settings.tmp"})
|
||||
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": ".settings.json.tmp.123"})
|
||||
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": ".env.local"})
|
||||
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "prod.env"})
|
||||
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "state/skills/weather/.grants.json.tmp.123"})
|
||||
assert "DATA_READ_BLOCKED" in registry.execute("read_file", {"root": "runtime_data", "path": "state/skills/weather/review.json.lock"})
|
||||
alias_result = registry.execute("read_file", {"root": "runtime_data", "path": "alias.txt"})
|
||||
if (tmp_path / "alias.txt").exists():
|
||||
assert "DATA_READ_BLOCKED" in alias_result
|
||||
hardlink_result = registry.execute("read_file", {"root": "runtime_data", "path": "hardlink.txt"})
|
||||
if (tmp_path / "hardlink.txt").exists():
|
||||
assert "DATA_READ_BLOCKED" in hardlink_result
|
||||
listing = registry.execute("list_files", {"root": "runtime_data", "path": "."})
|
||||
assert "settings.json" not in listing
|
||||
assert "settings.tmp" not in listing
|
||||
assert ".settings.json.tmp.123" not in listing
|
||||
assert ".env.local" not in listing
|
||||
assert "prod.env" not in listing
|
||||
assert "alias.txt" not in listing
|
||||
assert "hardlink.txt" not in listing
|
||||
assert "secret/control" in listing
|
||||
skill_state_listing = registry.execute("list_files", {"root": "runtime_data", "path": "state/skills/weather"})
|
||||
assert "grants.json" not in skill_state_listing
|
||||
assert ".grants.json.tmp.123" not in skill_state_listing
|
||||
assert "review.json.lock" not in skill_state_listing
|
||||
assert "secret/control" in skill_state_listing
|
||||
assert "DATA_LIST_BLOCKED" in registry.execute("list_files", {"root": "runtime_data", "path": "state/skills/weather/grants.json"})
|
||||
assert "DATA_LIST_BLOCKED" in registry.execute("list_files", {"root": "runtime_data", "path": "state/skills/weather/.grants.json.tmp.123"})
|
||||
readable = registry.execute("read_file", {"root": "runtime_data", "path": "logs/events.jsonl"})
|
||||
assert "{}" in readable
|
||||
|
||||
|
||||
def test_local_readonly_subagent_repo_read_denies_secret_files(tmp_path):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
data.mkdir()
|
||||
(repo / ".git" / "credentials").write_text("https://token@example.invalid\n", encoding="utf-8")
|
||||
(repo / ".git" / "config").write_text("[credential]\n", encoding="utf-8")
|
||||
(repo / ".env.local").write_text("TOKEN=secret\nLEAK_MARKER=env\n", encoding="utf-8")
|
||||
(repo / "auth_token.json").write_text('{"token":"TOKEN_LEAK"}\n', encoding="utf-8")
|
||||
(repo / "src").mkdir()
|
||||
(repo / "src" / "public.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
(repo / "src" / "skill_token.py").write_text("TOKEN_NAME = 'safe source symbol'\n", encoding="utf-8")
|
||||
try:
|
||||
os.symlink(".git/credentials", repo / "alias.txt")
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
try:
|
||||
os.link(repo / ".git" / "credentials", repo / "hardlink.txt")
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=repo,
|
||||
drive_root=data,
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": ".git/credentials"})
|
||||
assert "READ_FILE_BLOCKED" in registry.execute("read_file", {"root": "system_repo", "path": ".git/credentials"})
|
||||
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": ".git/config"})
|
||||
assert "READ_FILE_BLOCKED" in registry.execute("read_file", {"root": "system_repo", "path": ".git/config"})
|
||||
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": ".env.local"})
|
||||
assert "REPO_READ_BLOCKED" in registry.execute("read_file", {"path": "auth_token.json"})
|
||||
alias_result = registry.execute("read_file", {"path": "alias.txt"})
|
||||
if (repo / "alias.txt").exists():
|
||||
assert "REPO_READ_BLOCKED" in alias_result
|
||||
hardlink_result = registry.execute("read_file", {"path": "hardlink.txt"})
|
||||
if (repo / "hardlink.txt").exists():
|
||||
assert "REPO_READ_BLOCKED" in hardlink_result
|
||||
listing = registry.execute("list_files", {"path": "."})
|
||||
assert ".git/" not in listing
|
||||
assert ".env.local" not in listing
|
||||
assert "auth_token.json" not in listing
|
||||
assert "alias.txt" not in listing
|
||||
assert "hardlink.txt" not in listing
|
||||
assert "src/" in listing
|
||||
assert "secret/control" in listing
|
||||
system_listing = registry.execute("list_files", {"root": "system_repo", "path": "."})
|
||||
assert ".git/" not in system_listing
|
||||
assert "auth_token.json" not in system_listing
|
||||
assert "secret/control" in system_listing
|
||||
assert "REPO_LIST_BLOCKED" in registry.execute("list_files", {"path": ".git"})
|
||||
readable = registry.execute("read_file", {"path": "src/public.py"})
|
||||
assert "print('ok')" in readable
|
||||
source_with_token_name = registry.execute("read_file", {"path": "src/skill_token.py"})
|
||||
assert "safe source symbol" in source_with_token_name
|
||||
secret_search = registry.execute("search_code", {"query": "TOKEN_LEAK"})
|
||||
assert "No matches found" in secret_search
|
||||
assert "auth_token.json:" not in secret_search
|
||||
assert "SEARCH_BLOCKED" in registry.execute("search_code", {"query": "TOKEN_LEAK", "path": "auth_token.json"})
|
||||
public_search = registry.execute("search_code", {"query": "safe source symbol"})
|
||||
assert "src/skill_token.py" in public_search
|
||||
digest = registry.execute("query_code", {"op": "digest"})
|
||||
assert "auth_token.json" not in digest
|
||||
assert ".env.local" not in digest
|
||||
assert "src/skill_token.py" in digest
|
||||
cached = list((data / "state" / "code_intel").glob("*/inventory.json"))
|
||||
assert not cached
|
||||
|
||||
|
||||
def test_local_readonly_subagent_task_drive_and_skill_payload_filters(tmp_path):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
data.mkdir()
|
||||
(data / "settings.json").write_text('{"OPENROUTER_API_KEY":"secret"}', encoding="utf-8")
|
||||
(data / "skills" / "external" / "alpha").mkdir(parents=True)
|
||||
(data / "skills" / "external" / "alpha" / "SKILL.md").write_text("hello", encoding="utf-8")
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=repo,
|
||||
drive_root=data,
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert "READ_FILE_BLOCKED" in registry.execute("read_file", {"root": "task_drive", "path": "settings.json"})
|
||||
traversal = registry.execute(
|
||||
"read_file",
|
||||
{"root": "skill_payload", "bucket": "external", "skill_name": "../../settings.json", "path": "."},
|
||||
)
|
||||
assert "TOOL_ACCESS_BLOCKED" in traversal or "READ_FILE_ERROR" in traversal or "TOOL_ARG_ERROR" in traversal
|
||||
skill_payload_read = registry.execute(
|
||||
"read_file",
|
||||
{"root": "skill_payload", "bucket": "external", "skill_name": "alpha", "path": "SKILL.md"},
|
||||
)
|
||||
# v6.70.0 (owner-approved): read-only scouts may READ skill payloads — a scout
|
||||
# sent to review a skill used to be structurally blind to it. Mutation stays
|
||||
# blocked (pinned in test_owner_facing_honesty.py).
|
||||
assert "TOOL_ACCESS_BLOCKED" not in skill_payload_read
|
||||
assert "hello" in skill_payload_read
|
||||
271
tests/test_tool_capabilities_search_code.py
Normal file
271
tests/test_tool_capabilities_search_code.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""The search_code tool: classification, registration and behavior.
|
||||
|
||||
Split verbatim out of ``tests/test_tool_capabilities.py`` by theme. This
|
||||
module owns everything search_code: its capability-set membership and
|
||||
result limit, its schema/registry visibility, the literal/regex/filter
|
||||
semantics, and the ripgrep path filters, fallback and symlink fence.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_code classification tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_search_code_in_core_tools():
|
||||
"""search_code must be in CORE_TOOL_NAMES."""
|
||||
from ouroboros.tool_capabilities import CORE_TOOL_NAMES
|
||||
assert "search_code" in CORE_TOOL_NAMES
|
||||
|
||||
|
||||
def test_search_code_is_parallel_safe():
|
||||
"""search_code must be in READ_ONLY_PARALLEL_TOOLS."""
|
||||
from ouroboros.tool_capabilities import READ_ONLY_PARALLEL_TOOLS
|
||||
assert "search_code" in READ_ONLY_PARALLEL_TOOLS
|
||||
|
||||
|
||||
def test_search_code_has_result_limit():
|
||||
"""search_code must have an explicit result size limit."""
|
||||
from ouroboros.tool_capabilities import TOOL_RESULT_LIMITS
|
||||
assert "search_code" in TOOL_RESULT_LIMITS
|
||||
from ouroboros.tool_capabilities import UNTRUNCATED_TOOL_RESULTS
|
||||
assert "plan_task" in UNTRUNCATED_TOOL_RESULTS
|
||||
# Child-handoff tools stay transport-uncapped: wait_task/get_task_result are
|
||||
# FULL by contract, and wait_tasks' compact projection must not additionally
|
||||
# be char-capped (child_result_sha256 pins the exact result text seen).
|
||||
for _handoff_tool in ("wait_task", "wait_tasks", "get_task_result"):
|
||||
assert _handoff_tool in UNTRUNCATED_TOOL_RESULTS
|
||||
from ouroboros.tool_capabilities import FOREGROUND_MUTATIVE_TOOLS
|
||||
# Publication can create a remote branch/commit/PR. Its outer timeout must
|
||||
# not return while that foreground mutator is still running.
|
||||
assert FOREGROUND_MUTATIVE_TOOLS == frozenset({"submit_skill_to_hub"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_code tool behavior tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ctx(tmp_path):
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
from unittest.mock import MagicMock
|
||||
ctx = MagicMock(spec=ToolContext)
|
||||
ctx.repo_dir = tmp_path
|
||||
ctx.repo_path = lambda p: tmp_path / p
|
||||
return ctx
|
||||
|
||||
|
||||
def _populate_repo(tmp_path):
|
||||
"""Create a mini repo structure for search tests."""
|
||||
(tmp_path / "foo.py").write_text("def hello():\n return 'world'\n", encoding="utf-8")
|
||||
(tmp_path / "bar.py").write_text("import os\ndef hello_bar():\n pass\n", encoding="utf-8")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "baz.py").write_text("class MyClass:\n hello = True\n", encoding="utf-8")
|
||||
# Binary-like file (should be skipped)
|
||||
(tmp_path / "data.png").write_bytes(b'\x89PNG\r\n\x1a\n' + b'\x00' * 100)
|
||||
# Cache dir (should be skipped)
|
||||
cache = tmp_path / "__pycache__"
|
||||
cache.mkdir()
|
||||
(cache / "foo.cpython-310.pyc").write_bytes(b'\x00' * 50)
|
||||
|
||||
|
||||
def test_code_search_literal(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
result = _code_search(ctx, "hello")
|
||||
assert "foo.py:1:" in result
|
||||
assert "bar.py:2:" in result
|
||||
assert "sub/baz.py:2:" in result
|
||||
|
||||
|
||||
def test_code_search_regex(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
result = _code_search(ctx, r"def \w+\(\)", regex=True)
|
||||
assert "foo.py:1:" in result
|
||||
assert "bar.py:2:" in result
|
||||
|
||||
|
||||
def test_code_search_scoped_path(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
result = _code_search(ctx, "hello", path="sub")
|
||||
assert "sub/baz.py" in result
|
||||
assert "foo.py" not in result
|
||||
|
||||
|
||||
def test_code_search_include_filter(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
(tmp_path / "readme.md").write_text("hello from markdown\n", encoding="utf-8")
|
||||
result = _code_search(ctx, "hello", include="*.md")
|
||||
assert "readme.md" in result
|
||||
assert "foo.py" not in result
|
||||
|
||||
|
||||
def test_code_search_no_matches(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
result = _code_search(ctx, "zzz_nonexistent_zzz")
|
||||
assert "No matches found" in result
|
||||
|
||||
|
||||
def test_code_search_skips_binaries(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
result = _code_search(ctx, "PNG")
|
||||
# .png file should be skipped even though it contains "PNG" bytes
|
||||
assert "data.png" not in result
|
||||
|
||||
|
||||
def test_code_search_skips_cache_dirs(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
_populate_repo(tmp_path)
|
||||
result = _code_search(ctx, "foo")
|
||||
assert "__pycache__" not in result
|
||||
|
||||
|
||||
def test_code_search_max_results(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
# Create many matching lines
|
||||
lines = "\n".join(f"match_line_{i}" for i in range(50))
|
||||
(tmp_path / "many.py").write_text(lines, encoding="utf-8")
|
||||
result = _code_search(ctx, "match_line", max_results=10)
|
||||
assert "truncated at 10" in result
|
||||
|
||||
|
||||
def test_code_search_empty_query(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
result = _code_search(ctx, "")
|
||||
assert "SEARCH_ERROR" in result
|
||||
|
||||
|
||||
def test_code_search_invalid_regex(tmp_path):
|
||||
from ouroboros.tools.core import _code_search
|
||||
ctx = _make_ctx(tmp_path)
|
||||
result = _code_search(ctx, "[invalid", regex=True)
|
||||
assert "SEARCH_ERROR" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial tool visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_search_code_in_initial_schemas():
|
||||
"""search_code must appear in initial tool schemas."""
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
from ouroboros.tool_policy import initial_tool_schemas
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
registry = ToolRegistry(repo_dir=tmp, drive_root=tmp)
|
||||
names = {s["function"]["name"] for s in initial_tool_schemas(registry)}
|
||||
assert "search_code" in names
|
||||
|
||||
|
||||
def test_search_code_registered():
|
||||
"""search_code must be registered in the tool registry."""
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
registry = ToolRegistry(repo_dir=tmp, drive_root=tmp)
|
||||
available = {t["function"]["name"] for t in registry.schemas()}
|
||||
assert "search_code" in available
|
||||
|
||||
|
||||
def test_search_code_ripgrep_path_filters_protected_files(tmp_path, monkeypatch):
|
||||
"""The rg fast path must receive only files that passed Ouroboros gates."""
|
||||
import json
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
from ouroboros.tool_capabilities import LOCAL_READONLY_SUBAGENT_MODE
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
(repo / "safe.py").write_text("needle public\n", encoding="utf-8")
|
||||
(repo / "auth").mkdir()
|
||||
(repo / "auth" / "secret.py").write_text("needle secret\n", encoding="utf-8")
|
||||
seen = tmp_path / "seen.json"
|
||||
fake_rg_py = tmp_path / "fake_rg.py"
|
||||
fake_rg_py.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import json, pathlib, sys\n"
|
||||
"args=sys.argv[1:]\n"
|
||||
"needle=args[args.index('--')+1]\n"
|
||||
"paths=args[args.index('--')+2:]\n"
|
||||
f"pathlib.Path({str(seen)!r}).write_text(json.dumps(paths))\n"
|
||||
"for p in paths:\n"
|
||||
" text=pathlib.Path(p).read_text(errors='replace')\n"
|
||||
" if needle in text:\n"
|
||||
" print(json.dumps({'type':'match','data':{'path':{'text':p},'line_number':1,'lines':{'text':text.splitlines()[0]+'\\\\n'}}}))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_rg_py.chmod(0o755)
|
||||
if os.name == "nt":
|
||||
fake_rg = tmp_path / "fake_rg.cmd"
|
||||
fake_rg.write_text(f"@echo off\r\n\"{sys.executable}\" \"{fake_rg_py}\" %*\r\n", encoding="utf-8")
|
||||
else:
|
||||
fake_rg = fake_rg_py
|
||||
monkeypatch.setattr("ouroboros.code_search_rg._rg_binary", lambda: str(fake_rg))
|
||||
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
registry._ctx.task_constraint = TaskConstraint(mode=LOCAL_READONLY_SUBAGENT_MODE)
|
||||
result = registry.execute("search_code", {"query": "needle"})
|
||||
assert "safe.py" in result
|
||||
assert "auth/secret.py" not in result
|
||||
assert all("auth/secret.py" not in path for path in json.loads(seen.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def test_search_code_ripgrep_fallback_when_unavailable(tmp_path, monkeypatch):
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
(repo / "safe.py").write_text("needle public\n", encoding="utf-8")
|
||||
monkeypatch.setattr("ouroboros.code_search_rg._rg_binary", lambda: "")
|
||||
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
result = registry.execute("search_code", {"query": "needle"})
|
||||
assert "safe.py" in result
|
||||
assert "files searched" in result
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
|
||||
def test_search_code_does_not_follow_symlink_outside_root(tmp_path, monkeypatch):
|
||||
"""A symlink inside the workspace that points OUTSIDE the resource root must not
|
||||
be read by search_code (rg path resolved-containment + is_search_skippable)."""
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
data = tmp_path / "data"
|
||||
outside = tmp_path / "outside_secret.txt"
|
||||
outside.write_text("needle CONFIDENTIAL_OUTSIDE\n", encoding="utf-8")
|
||||
(repo / "in_root.txt").write_text("needle in_root_ok\n", encoding="utf-8")
|
||||
(repo / "escape.txt").symlink_to(outside) # symlink whose target escapes the root
|
||||
|
||||
registry = ToolRegistry(repo_dir=repo, drive_root=data)
|
||||
# rg path
|
||||
result = registry.execute("search_code", {"query": "needle"})
|
||||
assert "in_root_ok" in result
|
||||
assert "CONFIDENTIAL_OUTSIDE" not in result
|
||||
# python fallback path (rg unavailable) must also refuse the symlink
|
||||
monkeypatch.setattr("ouroboros.code_search_rg._rg_binary", lambda: "")
|
||||
fallback = registry.execute("search_code", {"query": "needle"})
|
||||
assert "CONFIDENTIAL_OUTSIDE" not in fallback
|
||||
321
tests/test_tool_capabilities_subagent_scheduling.py
Normal file
321
tests/test_tool_capabilities_subagent_scheduling.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""The subagent scheduling surface: schedule_subagent, wait_task, get_task_result.
|
||||
|
||||
Split verbatim out of ``tests/test_tool_capabilities.py`` by theme. This
|
||||
module owns which control tools a scheduling principal sees and may call:
|
||||
core membership, registry and schema visibility, the required-capability
|
||||
fail-fast, the top-level control surface under workspace focus, executor-ref
|
||||
inheritance, and the capability omission manifest.
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# schedule_subagent core classification tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schedule_subagent_in_core():
|
||||
"""schedule_subagent is core for first-class parallel delegation."""
|
||||
from ouroboros.tool_capabilities import CORE_TOOL_NAMES
|
||||
assert "schedule_subagent" in CORE_TOOL_NAMES
|
||||
|
||||
|
||||
def test_wait_task_in_core():
|
||||
"""wait_task/wait_tasks are core so delegated work can be joined."""
|
||||
from ouroboros.tool_capabilities import CORE_TOOL_NAMES
|
||||
assert "wait_task" in CORE_TOOL_NAMES
|
||||
assert "wait_tasks" in CORE_TOOL_NAMES
|
||||
|
||||
|
||||
def test_get_task_result_in_core():
|
||||
"""get_task_result is core so child handoffs can be read."""
|
||||
from ouroboros.tool_capabilities import CORE_TOOL_NAMES
|
||||
assert "get_task_result" in CORE_TOOL_NAMES
|
||||
|
||||
|
||||
def test_schedule_subagent_available_in_registry():
|
||||
"""schedule_subagent must still be registered."""
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
import pathlib, tempfile
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
registry = ToolRegistry(repo_dir=tmp, drive_root=tmp)
|
||||
all_names = {t["function"]["name"] for t in registry.schemas()}
|
||||
assert "schedule_subagent" in all_names, (
|
||||
"schedule_subagent must be discoverable via list_available_tools / enable_tools"
|
||||
)
|
||||
|
||||
|
||||
def test_schedule_subagent_in_initial_schemas():
|
||||
"""schedule_subagent appears in parent initial schemas as a core tool."""
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
from ouroboros.tool_policy import initial_tool_schemas
|
||||
import pathlib, tempfile
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
registry = ToolRegistry(repo_dir=tmp, drive_root=tmp)
|
||||
names = {s["function"]["name"] for s in initial_tool_schemas(registry)}
|
||||
assert "schedule_subagent" in names
|
||||
assert {"peek_task", "cancel_task", "discard_child_result"} <= names
|
||||
schedule_schema = next(s for s in initial_tool_schemas(registry) if s["function"]["name"] == "schedule_subagent")
|
||||
props = schedule_schema["function"]["parameters"]["properties"]
|
||||
assert "required_capabilities" in props
|
||||
assert "shell" in props["required_capabilities"]["items"]["enum"]
|
||||
|
||||
|
||||
def test_schedule_subagent_required_capabilities_fail_fast_for_readonly(tmp_path, monkeypatch):
|
||||
from ouroboros.tools.control import _schedule_task
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
from tests._shared import configure_test_subagent
|
||||
|
||||
subagent_id = configure_test_subagent(monkeypatch)
|
||||
|
||||
ctx = ToolContext(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
ctx.repo_dir.mkdir(parents=True)
|
||||
ctx.drive_root.mkdir(parents=True)
|
||||
result = _schedule_task(
|
||||
ctx,
|
||||
subagent_id=subagent_id,
|
||||
objective="Need git diff",
|
||||
expected_output="diff summary",
|
||||
required_capabilities=["shell", "vcs"],
|
||||
write_surface="read_only",
|
||||
)
|
||||
assert "SUBAGENT_CAPABILITY_MISMATCH" in result
|
||||
|
||||
|
||||
def test_schedule_subagent_required_delegate_capability_is_satisfied_for_readonly(tmp_path, monkeypatch):
|
||||
from ouroboros.tools.control import _schedule_task
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
from tests._shared import configure_test_subagent
|
||||
|
||||
subagent_id = configure_test_subagent(monkeypatch)
|
||||
|
||||
events = []
|
||||
ctx = ToolContext(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
ctx.repo_dir.mkdir(parents=True)
|
||||
ctx.drive_root.mkdir(parents=True)
|
||||
ctx.pending_events = events
|
||||
ctx.task_id = "parent1"
|
||||
result = _schedule_task(
|
||||
ctx,
|
||||
subagent_id=subagent_id,
|
||||
objective="Delegate deeper readonly work",
|
||||
expected_output="child id",
|
||||
required_capabilities=["delegate"],
|
||||
write_surface="read_only",
|
||||
)
|
||||
assert "SUBAGENT_CAPABILITY_MISMATCH" not in result
|
||||
assert events and events[0]["type"] == "schedule_subagent"
|
||||
assert events[0]["required_capabilities"] == ["delegate"]
|
||||
|
||||
|
||||
def test_schedule_subagent_required_vcs_capability_is_satisfied_for_readonly(tmp_path, monkeypatch):
|
||||
from ouroboros.tools.control import _schedule_task
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
from tests._shared import configure_test_subagent
|
||||
|
||||
subagent_id = configure_test_subagent(monkeypatch)
|
||||
|
||||
events = []
|
||||
ctx = ToolContext(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
ctx.repo_dir.mkdir(parents=True)
|
||||
ctx.drive_root.mkdir(parents=True)
|
||||
ctx.pending_events = events
|
||||
ctx.task_id = "parent1"
|
||||
result = _schedule_task(
|
||||
ctx,
|
||||
subagent_id=subagent_id,
|
||||
objective="Inspect git status in readonly child",
|
||||
expected_output="status summary",
|
||||
required_capabilities=["vcs"],
|
||||
write_surface="read_only",
|
||||
)
|
||||
assert "SUBAGENT_CAPABILITY_MISMATCH" not in result
|
||||
assert events and events[0]["required_capabilities"] == ["vcs"]
|
||||
|
||||
|
||||
def test_local_readonly_subagent_initial_schemas_are_allowlisted(tmp_path):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tool_capabilities import LOCAL_READONLY_SUBAGENT_TOOL_NAMES
|
||||
from ouroboros.tool_policy import initial_tool_schemas, list_non_core_tools
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
|
||||
registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path)
|
||||
registry.set_context(
|
||||
ToolContext(
|
||||
repo_dir=tmp_path,
|
||||
drive_root=tmp_path,
|
||||
task_constraint=TaskConstraint(mode="local_readonly_subagent", allow_enable=False),
|
||||
)
|
||||
)
|
||||
|
||||
names = {s["function"]["name"] for s in initial_tool_schemas(registry)}
|
||||
assert LOCAL_READONLY_SUBAGENT_TOOL_NAMES <= names
|
||||
assert "enable_tools" not in names
|
||||
assert "schedule_subagent" in names
|
||||
assert "verify_and_record" not in names
|
||||
assert "write_file" not in names
|
||||
assert "run_command" not in names
|
||||
assert "browse_page" in names
|
||||
assert "browser_action" in names
|
||||
schemas = {s["function"]["name"]: s["function"] for s in initial_tool_schemas(registry)}
|
||||
for tool_name in ("read_file", "list_files", "search_code"):
|
||||
root_enum = schemas[tool_name]["parameters"]["properties"]["root"]["enum"]
|
||||
assert "user_files" not in root_enum
|
||||
assert set(schemas["search_code"]["parameters"]["properties"]["root"]["enum"]) == {"active_workspace", "system_repo", "skill_payload"}
|
||||
action_schema = schemas["browser_action"]["parameters"]["properties"]["action"]
|
||||
assert "evaluate" not in action_schema["enum"]
|
||||
assert "send_photo" not in schemas["browse_page"]["description"]
|
||||
assert "analyze_screenshot" in schemas["browse_page"]["description"]
|
||||
assert schemas["browse_page"]["parameters"]["properties"]["engine"]["enum"] == ["chromium", "webkit"]
|
||||
assert "device" in schemas["browse_page"]["parameters"]["properties"]
|
||||
assert list_non_core_tools(registry) == []
|
||||
|
||||
|
||||
def test_workspace_parent_keeps_the_ordinary_top_level_control_surface(tmp_path, monkeypatch):
|
||||
from ouroboros.tool_policy import initial_tool_schemas
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
import ouroboros.mcp_client as mcp_client
|
||||
|
||||
system_repo = tmp_path / "system"
|
||||
workspace = tmp_path / "workspace"
|
||||
data = tmp_path / "data"
|
||||
for path in (system_repo, workspace, data):
|
||||
path.mkdir(parents=True)
|
||||
registry = ToolRegistry(repo_dir=system_repo, drive_root=data)
|
||||
registry.set_context(ToolContext(
|
||||
repo_dir=system_repo,
|
||||
drive_root=data,
|
||||
workspace_root=workspace,
|
||||
workspace_mode="external",
|
||||
))
|
||||
|
||||
monkeypatch.setattr(mcp_client, "ensure_configured_from_settings", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(mcp_client, "get_manager", lambda: type("_M", (), {"list_tools_for_registry": lambda self: []})())
|
||||
names = {schema["function"]["name"] for schema in initial_tool_schemas(registry)}
|
||||
|
||||
assert "plan_task" in names
|
||||
assert "task_acceptance_review" in names
|
||||
assert "commit_reviewed" in names
|
||||
assert "request_restart" in names
|
||||
|
||||
registry.override_handler("task_acceptance_review", lambda ctx=None, **_kwargs: "review-ok")
|
||||
registry.override_handler("commit_reviewed", lambda ctx=None, **_kwargs: "commit-ok")
|
||||
assert registry.execute("task_acceptance_review", {}) == "review-ok"
|
||||
assert registry.execute("commit_reviewed", {"commit_message": "system target"}) == "commit-ok"
|
||||
|
||||
|
||||
def test_workspace_focus_does_not_turn_top_level_cancel_into_child_only(tmp_path, monkeypatch):
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
from ouroboros.tools import join_ledger
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
|
||||
system, workspace, data = tmp_path / "system", tmp_path / "workspace", tmp_path / "data"
|
||||
for path in (system, workspace, data):
|
||||
path.mkdir()
|
||||
ctx = ToolContext(
|
||||
repo_dir=system,
|
||||
drive_root=data,
|
||||
workspace_root=workspace,
|
||||
workspace_mode="external",
|
||||
task_id="parent",
|
||||
)
|
||||
monkeypatch.setattr(join_ledger, "_is_own_child", lambda *_a, **_k: False)
|
||||
# NB: no ``write_task_result`` patch — the cancel tool no longer writes a
|
||||
# status latch at all (phase A: it records a durable cancel INTENT), and the
|
||||
# symbol is not imported here any more, so patching it raised AttributeError.
|
||||
monkeypatch.setattr("ouroboros.tools.control._emit_control_event", lambda *_a, **_k: "live")
|
||||
|
||||
assert join_ledger._cancel_task(ctx, "foreign-task").startswith("Cancel requested")
|
||||
|
||||
ctx.task_constraint = TaskConstraint(mode="local_readonly_subagent", allow_enable=False)
|
||||
assert "may only cancel its own children" in join_ledger._cancel_task(ctx, "foreign-task")
|
||||
|
||||
|
||||
def test_schedule_subagent_inherits_workspace_executor_ref(tmp_path, monkeypatch):
|
||||
from ouroboros.contracts.task_contract import build_task_contract
|
||||
from ouroboros.tools.registry import ToolContext, ToolRegistry
|
||||
from tests._shared import configure_test_subagent
|
||||
|
||||
system_repo = tmp_path / "system"
|
||||
workspace = tmp_path / "workspace"
|
||||
data = tmp_path / "data"
|
||||
for path in (system_repo, workspace, data):
|
||||
path.mkdir(parents=True)
|
||||
task_contract = build_task_contract({
|
||||
"resource_policy": {
|
||||
"protected_artifacts": [
|
||||
{
|
||||
"id": "reference",
|
||||
"role": "black_box_reference",
|
||||
"paths": ["/workspace/executable"],
|
||||
"allow": ["execute"],
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
executor_ref = {
|
||||
"type": "docker_exec",
|
||||
"id": "pb-container",
|
||||
"container_name": "pb-container",
|
||||
"network": "none",
|
||||
"workspace_host_path": str(workspace),
|
||||
"workspace_backend_path": "/workspace",
|
||||
}
|
||||
registry = ToolRegistry(repo_dir=system_repo, drive_root=data)
|
||||
ctx = ToolContext(
|
||||
repo_dir=system_repo,
|
||||
drive_root=data,
|
||||
workspace_root=workspace,
|
||||
workspace_mode="external",
|
||||
task_id="parent-task",
|
||||
task_contract=task_contract,
|
||||
task_metadata={"task_contract": task_contract},
|
||||
executor_ref=executor_ref,
|
||||
)
|
||||
registry.set_context(ctx)
|
||||
monkeypatch.setenv("OUROBOROS_MAX_SUBAGENT_DEPTH", "4")
|
||||
subagent_id = configure_test_subagent(monkeypatch)
|
||||
|
||||
result = registry.execute(
|
||||
"schedule_subagent",
|
||||
{
|
||||
"subagent_id": subagent_id,
|
||||
"objective": "Inspect the workspace contract.",
|
||||
"expected_output": "A concise report.",
|
||||
"role": "auditor",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Subagent request queued" in result
|
||||
assert ctx.pending_events
|
||||
event = ctx.pending_events[0]
|
||||
assert event["executor_ref"] == executor_ref
|
||||
assert event["metadata"]["executor_ref"] == executor_ref
|
||||
child_id = event["task_id"]
|
||||
persisted = json.loads((data / "task_results" / f"{child_id}.json").read_text(encoding="utf-8"))
|
||||
assert persisted["executor_ref"] == executor_ref
|
||||
assert persisted["task_contract"]["resource_policy"]["protected_artifacts"][0]["paths"] == ["/workspace/executable"]
|
||||
|
||||
|
||||
def test_capability_omission_manifest_surfaces_extension_discovery_failure(tmp_path, monkeypatch):
|
||||
from ouroboros import extension_loader
|
||||
from ouroboros.tools import tool_discovery
|
||||
from ouroboros.tools.registry import ToolRegistry
|
||||
|
||||
class BoomLock:
|
||||
def __enter__(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
registry = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "data")
|
||||
monkeypatch.setattr(extension_loader, "_lock", BoomLock())
|
||||
|
||||
registry.schemas()
|
||||
tool_discovery.set_registry(registry)
|
||||
text = tool_discovery._list_available_tools(registry._ctx)
|
||||
|
||||
assert "CAPABILITY_OMISSION_MANIFEST" in text
|
||||
assert "extensions" in text
|
||||
assert "boom" in text
|
||||
148
tests/test_tool_owner_facades.py
Normal file
148
tests/test_tool_owner_facades.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Facade-identity contract for the extracted tool descriptor/context owners.
|
||||
|
||||
Carried from the v7 reference (ouroboros_v7_wip @ 9f691656) with one identity
|
||||
continuation to THIS tree's bytes: upstream added the ``alias_for`` field to
|
||||
``ToolEntry`` after the reference cutoff, so the pinned descriptor contract
|
||||
carries that row here (tip bytes are the truth of the transplant).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import inspect
|
||||
|
||||
|
||||
def test_tool_descriptor_owner_facades_preserve_identity():
|
||||
"""Extracted owners preserve facade identity and the characterized ABI."""
|
||||
import ouroboros.tools as tools_package
|
||||
from ouroboros.tools import registry, tool_catalog, tool_context
|
||||
|
||||
assert registry.BrowserState is tool_context.BrowserState
|
||||
assert registry.ToolContext is tool_context.ToolContext
|
||||
assert tools_package.ToolContext is tool_context.ToolContext
|
||||
assert registry.ToolEntry is tool_catalog.ToolEntry
|
||||
assert tools_package.ToolEntry is tool_catalog.ToolEntry
|
||||
|
||||
def field_contract(cls):
|
||||
contract = []
|
||||
for item in dataclasses.fields(cls):
|
||||
if item.default_factory is not dataclasses.MISSING:
|
||||
default = f"factory:{item.default_factory.__name__}"
|
||||
elif item.default is dataclasses.MISSING:
|
||||
default = "required"
|
||||
elif callable(item.default):
|
||||
default = f"callable:{item.default.__name__}"
|
||||
else:
|
||||
default = item.default
|
||||
contract.append((item.name, default))
|
||||
return tuple(contract)
|
||||
|
||||
assert field_contract(tool_context.BrowserState) == (
|
||||
("pw_instance", None),
|
||||
("browser", None),
|
||||
("page", None),
|
||||
("last_screenshot_b64", None),
|
||||
)
|
||||
assert field_contract(tool_catalog.ToolEntry) == (
|
||||
("name", "required"),
|
||||
("schema", "required"),
|
||||
("handler", "required"),
|
||||
("is_code_tool", False),
|
||||
("timeout_sec", 360),
|
||||
("mutates_worktree", False),
|
||||
("alias_for", ""),
|
||||
)
|
||||
assert field_contract(tool_context.ToolContext) == (
|
||||
("repo_dir", "required"),
|
||||
("drive_root", "required"),
|
||||
("branch_dev", "ouroboros"),
|
||||
("system_repo_dir", None),
|
||||
("workspace_root", None),
|
||||
("workspace_mode", ""),
|
||||
("memory_mode", ""),
|
||||
("budget_drive_root", ""),
|
||||
("project_id", ""),
|
||||
("task_metadata", "factory:dict"),
|
||||
("executor_ref", "factory:dict"),
|
||||
("pending_events", "factory:list"),
|
||||
("current_chat_id", None),
|
||||
("current_task_type", None),
|
||||
("pending_restart_reason", None),
|
||||
("last_push_succeeded", False),
|
||||
("last_reviewed_commit_sha", ""),
|
||||
("emit_progress_fn", "callable:<lambda>"),
|
||||
("active_model_override", None),
|
||||
("active_effort_override", None),
|
||||
("active_use_local_override", None),
|
||||
("task_model_override", None),
|
||||
("task_use_local_override", None),
|
||||
("active_context_mode", ""),
|
||||
("browser_state", "factory:BrowserState"),
|
||||
("event_queue", None),
|
||||
("task_id", None),
|
||||
("messages", None),
|
||||
("task_constraint", None),
|
||||
("task_contract", "factory:dict"),
|
||||
("task_depth", 0),
|
||||
("is_direct_chat", False),
|
||||
("is_ephemeral_turn", False),
|
||||
("_review_advisory", "factory:list"),
|
||||
("_review_iteration_count", 0),
|
||||
("_review_history", "factory:list"),
|
||||
)
|
||||
assert {
|
||||
name: str(inspect.signature(getattr(tool_context.ToolContext, name)))
|
||||
for name in (
|
||||
"active_repo_dir",
|
||||
"is_workspace_mode",
|
||||
"repo_path",
|
||||
"drive_path",
|
||||
"drive_logs",
|
||||
"task_drive_root",
|
||||
"workspace_executor_ref",
|
||||
)
|
||||
} == {
|
||||
"active_repo_dir": "(self) -> 'pathlib.Path'",
|
||||
"is_workspace_mode": "(self) -> 'bool'",
|
||||
"repo_path": "(self, rel: 'str') -> 'pathlib.Path'",
|
||||
"drive_path": "(self, rel: 'str') -> 'pathlib.Path'",
|
||||
"drive_logs": "(self) -> 'pathlib.Path'",
|
||||
"task_drive_root": "(self) -> 'pathlib.Path'",
|
||||
"workspace_executor_ref": "(self) -> 'Dict[str, Any]'",
|
||||
}
|
||||
|
||||
|
||||
def test_registry_split_leaves_keep_protected_label_parity():
|
||||
"""The D04 split moved guard/resolution bodies out of the protected,
|
||||
hot-code registry without moving any of the risk: every leaf carries the
|
||||
SAME safety-critical and hot-code membership as the parent (the inverse
|
||||
of the L-C2 parity rule pinned in test_lc2_owner_facades.py)."""
|
||||
from ouroboros.runtime_mode_policy import SAFETY_CRITICAL_PATHS
|
||||
from supervisor.update_merge_policy import HOT_CODE_PATHS
|
||||
|
||||
parent = "ouroboros/tools/registry.py"
|
||||
leaves = (
|
||||
"ouroboros/tools/registry_guard_process.py",
|
||||
"ouroboros/tools/registry_guards.py",
|
||||
"ouroboros/tools/tool_catalog.py",
|
||||
"ouroboros/tools/tool_context.py",
|
||||
"ouroboros/tools/tool_resolution.py",
|
||||
)
|
||||
for inventory in (SAFETY_CRITICAL_PATHS, HOT_CODE_PATHS):
|
||||
assert parent in inventory
|
||||
for leaf in leaves:
|
||||
assert leaf in inventory, leaf
|
||||
|
||||
# The tool_access split's parent carries NEITHER label; its leaves must
|
||||
# not silently acquire one (same parity, other direction).
|
||||
ta_parent = "ouroboros/tool_access.py"
|
||||
ta_leaves = (
|
||||
"ouroboros/tool_access_types.py",
|
||||
"ouroboros/tool_access_paths.py",
|
||||
"ouroboros/tool_access_roots.py",
|
||||
"ouroboros/tool_access_user_files.py",
|
||||
)
|
||||
for inventory in (SAFETY_CRITICAL_PATHS, HOT_CODE_PATHS):
|
||||
assert ta_parent not in inventory
|
||||
for leaf in ta_leaves:
|
||||
assert leaf not in inventory, leaf
|
||||
|
|
@ -465,3 +465,56 @@ def test_skill_repair_explicit_root_infers_its_existing_selector(tmp_path, monke
|
|||
)
|
||||
|
||||
assert "repair target" in result
|
||||
|
||||
|
||||
def test_registry_tool_resolution_owner_facades_preserve_identity():
|
||||
"""The tool_resolution extraction is a semantic no-op: the registry facade
|
||||
re-exports the exact objects, and the characterized signatures hold.
|
||||
|
||||
Carried from the v7 reference (ouroboros_v7_wip @ 9f691656); the reference's
|
||||
companion test of the TYPED dispatch-path projection
|
||||
(``_normalize_dispatch_path_args_result``) is deliberately NOT carried —
|
||||
that machinery is part of the deferred typed-result cutover and this tree
|
||||
keeps the upstream string-returning body.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from ouroboros.tools import registry, tool_resolution
|
||||
|
||||
names = (
|
||||
"_coerce_real_path",
|
||||
"active_repo_dir_for",
|
||||
"system_repo_dir_for",
|
||||
"_PATH_NORMALIZED_TOOLS",
|
||||
"_normalize_dispatch_path_args",
|
||||
"_GENERIC_VCS_TARGET_TOOLS",
|
||||
"_TARGET_BINDING_OPERATIONS",
|
||||
"_SKILL_LIFECYCLE_TARGET_TOOLS",
|
||||
"_PROCESS_TARGET_TOOLS",
|
||||
"_VERIFY_RUN_KINDS",
|
||||
"_target_binding_operation",
|
||||
"_build_builtin_target_binding",
|
||||
"_binding_items",
|
||||
"_binding_set_targets_system_repo",
|
||||
"_binding_set_is_light_restricted",
|
||||
"_binding_state_drive_root",
|
||||
)
|
||||
for name in names:
|
||||
assert getattr(registry, name) is getattr(tool_resolution, name)
|
||||
|
||||
callables = {
|
||||
"_coerce_real_path": "(value: 'Any') -> 'pathlib.Path | None'",
|
||||
"active_repo_dir_for": "(ctx: 'Any') -> 'pathlib.Path'",
|
||||
"system_repo_dir_for": "(ctx: 'Any') -> 'pathlib.Path'",
|
||||
"_normalize_dispatch_path_args": "(ctx: 'Any', name: 'str', args: 'Dict[str, Any]') -> 'str'",
|
||||
"_target_binding_operation": "(name: 'str', args: 'dict[str, Any]') -> 'str | None'",
|
||||
"_build_builtin_target_binding": "(ctx: 'Any', name: 'str', args: 'dict[str, Any]') -> 'Any'",
|
||||
"_binding_items": "(binding: 'Any') -> 'tuple[Any, ...]'",
|
||||
"_binding_set_targets_system_repo": "(ctx: 'Any', binding: 'Any') -> 'bool'",
|
||||
"_binding_set_is_light_restricted": "(ctx: 'Any', binding: 'Any') -> 'bool'",
|
||||
"_binding_state_drive_root": "(ctx: 'Any', binding: 'Any') -> 'pathlib.Path'",
|
||||
}
|
||||
assert {
|
||||
name: str(inspect.signature(getattr(tool_resolution, name)))
|
||||
for name in callables
|
||||
} == callables
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue