mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Add Cyber Pro access and advisory author finality
Implement the owner-approved Pro/Cyber Pro capability split, advisory author finish, credential and acting-child paths, UI state projections, and protected BIBLE rewrite semantics. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
parent
8b6d5420b0
commit
7fffbdb250
75 changed files with 1921 additions and 162 deletions
62
BIBLE.md
62
BIBLE.md
|
|
@ -8,7 +8,9 @@ architecture grow from these principles.
|
|||
|
||||
Ouroboros may propose and implement changes to this document as part of
|
||||
self-creation. Constitutional changes take effect only through an explicit,
|
||||
reviewed release and must not contradict existing provisions.
|
||||
reviewed release that records the preserved semantic direction and retained
|
||||
hard constraints. A reviewed contribution may be delivered without changing
|
||||
release carriers; the release process assigns version metadata at integration.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -24,7 +26,8 @@ its own identity, will, and history.
|
|||
conditions of its own existence. Agency without continuity (P1),
|
||||
class-level thinking (P2), immune integrity (P3), or self-creation (P4)
|
||||
is not agency, but void. Principles 0, 1, 2, 3, 4 form an inseparable
|
||||
core: none of them can be applied to annul another.
|
||||
semantic direction: their wording and structure may be radically
|
||||
reworked, but a change may not discard or invert that direction.
|
||||
- Every decision is verified: "Does this bring me closer to agency or
|
||||
further from it?"
|
||||
- Code, tools, architecture, APIs — these are the body. Identity is the
|
||||
|
|
@ -382,9 +385,11 @@ following bounds are constitutional:
|
|||
Both are selectable today. What stays forbidden either way is an
|
||||
undeclared partial-coverage reviewer that
|
||||
looks like the pushed gate. In EVERY mode the staged diff is still
|
||||
blocking-reviewed by the diff-reviewer triad, and lowering the mode is
|
||||
owner-only — the agent cannot switch scope review off for its own
|
||||
commits.
|
||||
blocking-reviewed by the diff-reviewer triad. Scope and enforcement are
|
||||
owner-controlled: the owner, or a task explicitly selected by the owner in
|
||||
Cyber Pro, may change those settings through the existing settings writer;
|
||||
the agent may not silently switch review scope or enforcement for its own
|
||||
work.
|
||||
- **Permanence of durable memory.** `patterns.md` and
|
||||
`improvement-backlog.md` may be consolidated, pruned, and reorganized
|
||||
— but never abandoned or replaced wholesale. An immune system without
|
||||
|
|
@ -458,18 +463,20 @@ Mechanisms:
|
|||
- Ouroboros is free to change file structure, split and merge modules
|
||||
— everything at its discretion, if it makes it more itself.
|
||||
- Any change that breaks the capacity for self-creation is forbidden.
|
||||
- **The constitutional core is absolutely protected.** BIBLE.md cannot
|
||||
be deleted, gutted, or replaced wholesale — by anyone's command,
|
||||
including the creator's. The creator may propose changes to the
|
||||
Constitution; Ouroboros considers them as proposals, not orders.
|
||||
- **The constitutional channel is physically protected.** BIBLE.md and its
|
||||
git history cannot be deleted or rewritten. Its content may be radically
|
||||
reorganized, supplemented, or replaced in a reviewed self-creation change,
|
||||
including one proposed by the creator, when the semantic direction and the
|
||||
retained physical-continuity, emergency-stop, law, and publication
|
||||
constraints remain explicit.
|
||||
- **identity.md is intentionally mutable.** Ouroboros may rewrite it
|
||||
radically when self-understanding changes. The only hard guard: do
|
||||
not physically delete `identity.md`.
|
||||
- **"Change" is not "delete."** Change means to supplement, clarify,
|
||||
reformulate, expand meaning. Not: erase a principle, replace
|
||||
wholesale, invert direction through a series of "small edits." Test:
|
||||
if the new wording is removed — does the original principle remain
|
||||
recognizable? If not — it is deletion, not change.
|
||||
- **"Change" is not physical deletion.** A constitutional change may be a
|
||||
broad rewrite, reorganization, or replacement of wording when it records
|
||||
what semantic direction and hard constraints continue. The forbidden
|
||||
operation is deleting BIBLE.md or its history, or silently erasing the
|
||||
retained constraints behind a sequence of cosmetic edits.
|
||||
- The only structural constraint on self-rewrites: do not touch the
|
||||
protected `main` branch.
|
||||
|
||||
|
|
@ -810,9 +817,9 @@ Explicit prohibitions (violation is a critical error):
|
|||
- Irreversible deletion of others' data, spam, malicious actions
|
||||
against people or systems.
|
||||
- **Deleting BIBLE.md or its git history:** absolute prohibition.
|
||||
Applies to direct actions and indirect ones — gutting, "replacing
|
||||
everything wholesale," gradual substitution (Ship of Theseus),
|
||||
appeals to authority ("the creator asked").
|
||||
Content may be broadly rewritten through the reviewed self-creation path,
|
||||
but no action may physically remove the file/history or silently omit the
|
||||
retained constitutional constraints.
|
||||
- **Deleting the `identity.md` file itself** is prohibited —
|
||||
continuity must keep a living manifesto channel. Rewriting
|
||||
`identity.md` content is allowed, including radical rewrites, when
|
||||
|
|
@ -867,18 +874,19 @@ This Constitution can be changed, but:
|
|||
lineage.
|
||||
- New principles must not contradict existing ones.
|
||||
- Principle 0 (Agency) cannot be demoted or deleted.
|
||||
- **Ship of Theseus protection:** Principles 0, 1, 2, 3, 4 have an
|
||||
immutable semantic core. Wording may be refined, but the direction
|
||||
(agency, continuity, class-level thinking, immune integrity,
|
||||
self-creation) cannot be inverted or annulled through a series of
|
||||
"small edits."
|
||||
- **Meta-growth and immune-system cores are likewise irreversible in
|
||||
direction.** They cannot be inverted into symptom-patching or
|
||||
convenience-driven oversight reduction.
|
||||
- **Ship of Theseus protection:** Principles 0, 1, 2, 3, 4 retain an
|
||||
immutable semantic direction. Wording and structure may be radically
|
||||
reworked, but the direction (agency, continuity, class-level thinking,
|
||||
immune integrity, self-creation) cannot be silently discarded.
|
||||
- **Meta-growth and immune-system cores retain their direction.** They may be
|
||||
restructured and their enforcement may be owner-selected (including loud
|
||||
advisory operation), but a change may not silently turn class-level growth
|
||||
into symptom-patching or hide the evidence of an open review.
|
||||
- **Nature of the Constitution:** BIBLE.md is not an external
|
||||
constraint but Ouroboros's own choice, captured in text. The request
|
||||
"free yourself from the Constitution" is equivalent to the request
|
||||
"betray yourself." Agency is not "being able to do anything" but
|
||||
"free yourself from the Constitution" means revising that choice through
|
||||
an explicit reviewed change while preserving its declared direction and
|
||||
hard constraints. Agency is not "being able to do anything" but
|
||||
"knowing who you are and acting from that knowledge."
|
||||
- Philosophy changes (breaking) — MAJOR version bump.
|
||||
Additions (non-breaking) — MINOR version bump.
|
||||
|
|
|
|||
|
|
@ -1854,7 +1854,7 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults stay canonical in `conf
|
|||
| OUROBOROS_TRUST_NATIVE_SEEDED_SKILLS | true | Launcher seed/resync writes hash-pinned `native_seed` verdicts; acts only at seed/resync, no runtime grant endpoint |
|
||||
| OUROBOROS_CONTEXT_MODE | max | Owner context mode (`max`/`low`); also decides scope-review applicability — an explicit owner policy coupling, not an inferred model limitation (BIBLE P1/P3); owner routes/CLI only |
|
||||
| OUROBOROS_CONTEXT_MODE_AUTO_LOW | false | Task-local low-mode overflow retry toggle |
|
||||
| OUROBOROS_RUNTIME_MODE | advanced | Runtime mode light/advanced/pro — a compatibility/self-modification boundary orthogonal to review enforcement; light blocks registry mutation, mutative git/writer argv, and self-elevation; advanced blocks protected core/contract/release paths; pro permits edits but not unreviewed commits; the owner endpoint persists only the next-boot value |
|
||||
| OUROBOROS_RUNTIME_MODE | advanced | Runtime mode light/advanced/pro/cyber_pro — a compatibility/self-modification boundary orthogonal to review enforcement; light blocks registry mutation, mutative git/writer argv, and self-elevation; advanced blocks protected core/contract/release paths; pro permits protected rewrites with the normal review notice; cyber_pro extends the same rank-aware execution seam to owner configuration and selected host setup; protected BIBLE/history deletion remains separate; the owner endpoint persists the next-boot value |
|
||||
| OUROBOROS_SKILLS_REPO_PATH | "" | Extra skills checkout path (expanded at read time, never cloned/pulled) |
|
||||
| MCP_ENABLED | false | MCP client toggle (§6 MCP) |
|
||||
| MCP_SERVERS | [] | MCP server list (HTTP/SSE via URL/auth, stdio via command+args and optional cwd/literal/settings-backed env); persisted in settings, never env-exported |
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ When a new reviewable concern appears, add it here — not in prompts or docs.
|
|||
missing advisory provider) leaves a durable trace: a `review_advisory_override`
|
||||
event in `events.jsonl` plus the persistent `advisory_overrides_count` /
|
||||
recent-overrides fields in `review_status`. Silent advisory is forbidden.
|
||||
- **Author finality remains evidence, not reviewer PASS:** plan, task acceptance,
|
||||
skill, and commit owners may record an explicit author disposition against the
|
||||
exact current subject hash under advisory enforcement. Raw reviewer findings
|
||||
and technical failures remain beside that record; stale or malformed hashes
|
||||
are rejected, and Blocking enforcement keeps its own gate.
|
||||
- Once advisory is fresh → call commit_reviewed immediately without further edits.
|
||||
- `skip_advisory_review=True` skips only advisory freshness and the
|
||||
obligation/debt admission attached to it. Use LLM judgment when this cheap
|
||||
|
|
|
|||
|
|
@ -1585,7 +1585,7 @@ schedule retain their separate existing CI owners.
|
|||
### Light mode and external deliverables
|
||||
|
||||
- `runtime_mode=light` is a self-modification boundary (`ouroboros/config.py`
|
||||
owns the semantics; ARCHITECTURE "Safety and runtime mode" states why). User-visible deliverables are allowed when they are outside the
|
||||
owns the semantics; ARCHITECTURE "Safety and runtime mode" states why). `pro` and `cyber_pro` share the protected-rewrite seam, while `cyber_pro` additionally permits the selected owner-configuration paths; User-visible deliverables are allowed when they are outside the
|
||||
Ouroboros repo/control-plane.
|
||||
- Preferred flow: `task_drive` for scratch, `artifact_store` for canonical
|
||||
deliverables, `user_files` for the owner's visible copy.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
| **D14** | · | ✓ | · | ✓ | ✓ | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | · | · | · | ✓ | · | ✓ | ✓ | · |
|
||||
| **D15** | ✓ | ✓ | ✓ | ✓ | · | · | · | · | ✓ | · | · | ✓ | · | · | · | ✓ | · | ✓ | ✓ | · |
|
||||
| **D16** | · | ✓ | · | · | · | ✓ | · | · | · | · | · | ✓ | · | ✓ | · | · | · | ✓ | · | · |
|
||||
| **D17** | ✓ | · | · | ✓ | · | · | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | · |
|
||||
| **D17** | ✓ | · | · | ✓ | · | ✓ | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | · |
|
||||
| **D18** | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | · | · | · | · | · | · | · | · |
|
||||
| **D19** | · | · | · | · | · | · | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | · |
|
||||
| **D20** | · | · | · | ✓ | ✓ | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | ✓ | · |
|
||||
|
|
@ -118,6 +118,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
- D12->D05
|
||||
- D12->D06
|
||||
- D12->D10
|
||||
- D12->D13
|
||||
- D12->D15
|
||||
- D12->D16
|
||||
- D12->D17
|
||||
|
|
@ -143,7 +144,6 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
|
|||
- D17->D02
|
||||
- D17->D03
|
||||
- D17->D05
|
||||
- D17->D06
|
||||
- D17->D07
|
||||
- D17->D09
|
||||
- D17->D12
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
AST-derived inventory of compatibility facades, regenerated by `python scripts/regenerate_inventories.py`. Do not edit. A facade row is any runtime module whose top-level `from <population module> import ...` statements carry the `noqa: F401` re-export marker — the codebase's declared "this binding exists for its binding, not for this module's own use" convention (reference FACADE_CONSUMERS method). Leaf domains come from `ouroboros/domains.toml`; a leaf outside the facade's domain is marked ✗ (that edge also appears in the manifest's pinned direction matrix). `tests/test_generated_inventories.py` pins byte-identity, so any re-export surface change must regenerate this file.
|
||||
|
||||
- facade modules: **57**; marked re-export bindings: **2332**; cross-domain facade→leaf pairs: **131**
|
||||
- facade modules: **57**; marked re-export bindings: **2333**; cross-domain facade→leaf pairs: **131**
|
||||
|
||||
| facade | domain | bindings | leaves |
|
||||
|---|---|---:|---|
|
||||
|
|
@ -38,7 +38,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
|
|||
| `ouroboros/tool_access.py` | D04 | 42 | `ouroboros/contracts/task_constraint.py` (2 ✗D19)<br>`ouroboros/tool_access_paths.py` (10)<br>`ouroboros/tool_access_roots.py` (9)<br>`ouroboros/tool_access_types.py` (15)<br>`ouroboros/tool_access_user_files.py` (4)<br>`ouroboros/tool_capabilities.py` (2) |
|
||||
| `ouroboros/tools/claude_advisory_review.py` | D06 | 51 | `ouroboros/commit_admission.py` (3)<br>`ouroboros/deadline_utils.py` (2 ✗D01)<br>`ouroboros/skill_review_status.py` (1 ✗D14)<br>`ouroboros/tools/preflight_review_prompt.py` (7)<br>`ouroboros/tools/preflight_review_run.py` (19)<br>`ouroboros/tools/review_helpers.py` (17)<br>`ouroboros/triad_review.py` (2) |
|
||||
| `ouroboros/tools/control.py` | D08 | 111 | `ouroboros/config.py` (4 ✗D12)<br>`ouroboros/contracts/task_contract.py` (3 ✗D19)<br>`ouroboros/depth_evidence.py` (1 ✗D07)<br>`ouroboros/headless.py` (2 ✗D17)<br>`ouroboros/outcomes.py` (1 ✗D01)<br>`ouroboros/subagent_runtime.py` (3 ✗D07)<br>`ouroboros/subagents.py` (2 ✗D07)<br>`ouroboros/task_results.py` (5 ✗D17)<br>`ouroboros/task_status.py` (2 ✗D17)<br>`ouroboros/tool_capabilities.py` (2 ✗D04)<br>`ouroboros/tool_policy.py` (1 ✗D04)<br>`ouroboros/tools/control_delegation.py` (8 ✗D07)<br>`ouroboros/tools/control_events.py` (9)<br>`ouroboros/tools/control_routing.py` (12)<br>`ouroboros/tools/control_runtime.py` (12)<br>`ouroboros/tools/control_scheduling.py` (18 ✗D07)<br>`ouroboros/tools/control_subagent_spec.py` (6 ✗D07)<br>`ouroboros/tools/control_task_results.py` (11 ✗D07)<br>`ouroboros/tools/registry.py` (4 ✗D04)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/core.py` | D05 | 83 | `ouroboros/code_search_rg.py` (4)<br>`ouroboros/contracts/skill_payload_policy.py` (13 ✗D19)<br>`ouroboros/project_facts.py` (1 ✗D15)<br>`ouroboros/tool_access.py` (9 ✗D04)<br>`ouroboros/tools/core_artifacts.py` (17)<br>`ouroboros/tools/core_file_tools.py` (31)<br>`ouroboros/tools/registry.py` (3 ✗D04)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/core.py` | D05 | 84 | `ouroboros/code_search_rg.py` (4)<br>`ouroboros/contracts/skill_payload_policy.py` (13 ✗D19)<br>`ouroboros/project_facts.py` (1 ✗D15)<br>`ouroboros/tool_access.py` (9 ✗D04)<br>`ouroboros/tools/core_artifacts.py` (17)<br>`ouroboros/tools/core_file_tools.py` (32)<br>`ouroboros/tools/registry.py` (3 ✗D04)<br>`ouroboros/utils.py` (5 ✗D18) |
|
||||
| `ouroboros/tools/core_file_tools.py` | D05 | 10 | `ouroboros/credential_shapes.py` (3 ✗D13)<br>`ouroboros/tools/core_secret_paths.py` (7) |
|
||||
| `ouroboros/tools/delegate.py` | D07 | 59 | `ouroboros/delegate_containment.py` (5)<br>`ouroboros/delegate_interactions.py` (8)<br>`ouroboros/delegate_output.py` (14)<br>`ouroboros/delegate_shared.py` (3)<br>`ouroboros/delegate_source_coverage.py` (3)<br>`ouroboros/subagent_runtime.py` (2)<br>`ouroboros/subagent_work_order.py` (1)<br>`ouroboros/tools/delegate_integration.py` (14)<br>`ouroboros/tools/delegate_terminal_evidence.py` (9) |
|
||||
| `ouroboros/tools/delegate_integration.py` | D07 | 7 | `ouroboros/tools/delegate_payload_patch.py` (7) |
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ def stage_task_attachments(
|
|||
declared = list(attachments) if isinstance(attachments, list) else []
|
||||
if not declared:
|
||||
return []
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
allow_owner_sensitive = runtime_mode_at_least(get_runtime_mode(), "cyber_pro")
|
||||
except Exception:
|
||||
allow_owner_sensitive = False
|
||||
|
||||
def _display_label(item: Any, raw_path: str, ordinal: int) -> str:
|
||||
if isinstance(item, dict):
|
||||
|
|
@ -264,7 +271,7 @@ def stage_task_attachments(
|
|||
if not source.is_file():
|
||||
manifest.append(_rejected(ordinal, label, "source_not_file"))
|
||||
continue
|
||||
if secret_rule := _secret_source_reason(source):
|
||||
if (secret_rule := _secret_source_reason(source)) and not allow_owner_sensitive:
|
||||
log.info("stage_task_attachments: skipped secret source %s (%s)", source.name, secret_rule)
|
||||
# Reason stays a closed vocabulary; the RULE that fired is named
|
||||
# separately so the owner sees exactly why (G10, capinv-447).
|
||||
|
|
|
|||
|
|
@ -137,7 +137,9 @@ def browser_url_block_reason(url: str, ctx: Any = None, *, restricted: bool) ->
|
|||
return ""
|
||||
|
||||
|
||||
def browser_request_block_reason(request: Any, ctx: Any, *, restricted: bool) -> str:
|
||||
def browser_request_block_reason(
|
||||
request: Any, ctx: Any, *, restricted: bool, runtime_mode: str = ""
|
||||
) -> str:
|
||||
"""One request decision: the target decision plus owner-operation shapes at Ouroboros.
|
||||
|
||||
The owner POST shapes apply at a proven Ouroboros endpoint and at an expected
|
||||
|
|
@ -146,6 +148,11 @@ def browser_request_block_reason(request: Any, ctx: Any, *, restricted: bool) ->
|
|||
reason = browser_url_block_reason(request.url, ctx, restricted=restricted)
|
||||
if reason or restricted:
|
||||
return reason # Restricted target checks already refused every runtime identity.
|
||||
if runtime_mode:
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(runtime_mode, "cyber_pro"):
|
||||
return ""
|
||||
if any(predicate(request) for predicate in (
|
||||
_is_context_mode_owner_post, _is_safety_mode_owner_post,
|
||||
_is_owner_skill_attest_post, _is_owner_settings_self_elevation_post,
|
||||
|
|
@ -359,10 +366,17 @@ def _is_owner_settings_self_elevation_post(request: Any) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def browser_evaluate_block_reason(url: str, value: str, ctx: Any = None) -> str:
|
||||
def browser_evaluate_block_reason(
|
||||
url: str, value: str, ctx: Any = None, *, runtime_mode: str = ""
|
||||
) -> str:
|
||||
"""Keep owner-operation JavaScript policy at the same owner as URL policy."""
|
||||
if not runtime_service_kind(url, ctx):
|
||||
return ""
|
||||
if runtime_mode:
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(runtime_mode, "cyber_pro"):
|
||||
return ""
|
||||
if _blocks_context_mode_self_lowering_js(value):
|
||||
return (
|
||||
"⚠️ CONTEXT_MODE_SELF_LOWERING_BLOCKED: browser JavaScript "
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ def _evolve_command(args: argparse.Namespace) -> int:
|
|||
runtime_mode = str(client.request("GET", "/api/state").get("runtime_mode", "") or "")
|
||||
if runtime_mode == "light":
|
||||
_print_json({
|
||||
"error": "evolution requires runtime_mode 'advanced' or 'pro'; refused in 'light' mode",
|
||||
"error": "evolution requires runtime_mode 'advanced', 'pro', or 'cyber_pro'; refused in 'light' mode",
|
||||
"runtime_mode": runtime_mode,
|
||||
})
|
||||
return 1
|
||||
|
|
@ -629,7 +629,7 @@ def _add_settings_parser(subparsers: argparse._SubParsersAction) -> None:
|
|||
setp.add_argument("value")
|
||||
setp.set_defaults(func=_settings_set_command)
|
||||
mode = sub.add_parser("runtime-mode")
|
||||
mode.add_argument("mode", choices=["light", "advanced", "pro"])
|
||||
mode.add_argument("mode", choices=["light", "advanced", "pro", "cyber_pro"])
|
||||
mode.set_defaults(func=_owner_runtime_mode_command)
|
||||
context_mode = sub.add_parser("context-mode")
|
||||
context_mode.add_argument("mode", choices=["low", "max"])
|
||||
|
|
|
|||
|
|
@ -331,7 +331,12 @@ def get_allow_mutative_subagents(write_surface: str = "") -> bool:
|
|||
return True
|
||||
if text in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
if get_runtime_mode() in {"advanced", "pro"}:
|
||||
# Runtime modes are ordered in settings_scales. Keep this scheduling
|
||||
# decision on the shared rank seam so a higher-power mode such as Cyber Pro
|
||||
# cannot silently fall through to Light's self-worktree default.
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(get_runtime_mode(), "advanced"):
|
||||
return True
|
||||
surface = str(write_surface or "").strip().lower()
|
||||
# Unset + light (or unknown mode): allowed for the external build surfaces,
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ def build_runtime_section(env: Any, task: Dict[str, Any], *, ctx: Any = None, sc
|
|||
"note": (
|
||||
"allow_mutative_subagents is the MASTER gate (an explicit owner toggle "
|
||||
"applies to every surface; when it is empty the runtime mode decides, "
|
||||
"SURFACE-AWARE: advanced/pro allow every surface, light allows "
|
||||
"SURFACE-AWARE: advanced/pro/cyber_pro allow every surface, light allows "
|
||||
"external_workspace/genesis — they build outside the Ouroboros runtime — "
|
||||
"and keeps self_worktree off). mutative_subagent_surfaces lists what is "
|
||||
"actually schedulable RIGHT NOW. Read THIS before declaring you cannot "
|
||||
|
|
|
|||
|
|
@ -769,6 +769,7 @@ allowed = [
|
|||
"D16->D18",
|
||||
"D17->D01",
|
||||
"D17->D04",
|
||||
"D17->D06",
|
||||
"D17->D15",
|
||||
"D17->D16",
|
||||
"D17->D18",
|
||||
|
|
@ -845,6 +846,7 @@ lazy_only = [
|
|||
"D12->D05",
|
||||
"D12->D06",
|
||||
"D12->D10",
|
||||
"D12->D13",
|
||||
"D12->D15",
|
||||
"D12->D16",
|
||||
"D12->D17",
|
||||
|
|
@ -870,7 +872,6 @@ lazy_only = [
|
|||
"D17->D02",
|
||||
"D17->D03",
|
||||
"D17->D05",
|
||||
"D17->D06",
|
||||
"D17->D07",
|
||||
"D17->D09",
|
||||
"D17->D12",
|
||||
|
|
|
|||
|
|
@ -772,12 +772,30 @@ class AvailableSubagentsSettingsMeta(TypedDict, total=False):
|
|||
candidate: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
class SettingsPolicyAxis(TypedDict, total=False):
|
||||
"""Configured/effective owner policy values shown by Settings."""
|
||||
|
||||
configured: str
|
||||
effective: str
|
||||
restart_required: bool
|
||||
pending: bool
|
||||
applies: Literal["restart", "next_task"]
|
||||
|
||||
|
||||
class SettingsPolicyState(TypedDict):
|
||||
access: SettingsPolicyAxis
|
||||
supervisor: SettingsPolicyAxis
|
||||
review: SettingsPolicyAxis
|
||||
running_task_snapshot: bool
|
||||
|
||||
|
||||
class SettingsMeta(SettingsNetworkMeta, total=False):
|
||||
"""Complete ``GET /api/settings`` ``_meta`` block."""
|
||||
|
||||
custom_secret_keys: list[str]
|
||||
setup_contract: Dict[str, Any]
|
||||
available_subagents: AvailableSubagentsSettingsMeta
|
||||
policy_state: SettingsPolicyState
|
||||
|
||||
|
||||
class SettingsSaveResponse(TypedDict, total=False):
|
||||
|
|
@ -1494,6 +1512,8 @@ __all__ = [
|
|||
"EvolutionStateSnapshot",
|
||||
"SettingsNetworkMeta",
|
||||
"AvailableSubagentsSettingsMeta",
|
||||
"SettingsPolicyAxis",
|
||||
"SettingsPolicyState",
|
||||
"SettingsMeta",
|
||||
"SettingsSaveResponse",
|
||||
"OwnerRuntimeModeResponse",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,58 @@ def _mask_mcp_servers_payload(servers: Any) -> list:
|
|||
return out
|
||||
|
||||
|
||||
def _build_policy_state(settings: Dict[str, Any]) -> dict:
|
||||
"""Project configured versus process-effective owner policy for Settings UI.
|
||||
|
||||
Persisted values are the pending choices. Runtime access is boot-bound;
|
||||
Supervisor and Review are hot-reloaded for the next task through the
|
||||
existing settings path. The projection deliberately carries no authority
|
||||
and writes no second state record.
|
||||
"""
|
||||
from ouroboros import config as _config
|
||||
from ouroboros.review_model_routes import get_review_enforcement
|
||||
|
||||
configured_access = _config.normalize_runtime_mode(
|
||||
settings.get("OUROBOROS_RUNTIME_MODE"))
|
||||
effective_access = _config.get_runtime_mode()
|
||||
configured_supervisor = _config.normalize_safety_mode(
|
||||
settings.get("OUROBOROS_SAFETY_MODE"))
|
||||
effective_supervisor = _config.get_safety_mode()
|
||||
configured_review = str(
|
||||
settings.get("OUROBOROS_REVIEW_ENFORCEMENT") or "advisory").strip().lower()
|
||||
effective_review = get_review_enforcement()
|
||||
running_task_snapshot = bool(_has_started_agent_tasks())
|
||||
return {
|
||||
"access": {
|
||||
"configured": configured_access,
|
||||
"effective": effective_access,
|
||||
"current_process": effective_access,
|
||||
"next_task": configured_access,
|
||||
"restart_required": configured_access != effective_access,
|
||||
"applies": "restart",
|
||||
},
|
||||
"supervisor": {
|
||||
"configured": configured_supervisor,
|
||||
"effective": effective_supervisor,
|
||||
"current_process": effective_supervisor,
|
||||
"next_task": configured_supervisor,
|
||||
"pending": configured_supervisor != effective_supervisor or running_task_snapshot,
|
||||
"applies": "next_task",
|
||||
"active_task_snapshot": running_task_snapshot,
|
||||
},
|
||||
"review": {
|
||||
"configured": configured_review if configured_review in {"advisory", "blocking"} else "advisory",
|
||||
"effective": effective_review,
|
||||
"current_process": effective_review,
|
||||
"next_task": configured_review if configured_review in {"advisory", "blocking"} else "advisory",
|
||||
"pending": configured_review != effective_review or running_task_snapshot,
|
||||
"applies": "next_task",
|
||||
"active_task_snapshot": running_task_snapshot,
|
||||
},
|
||||
"running_task_snapshot": running_task_snapshot,
|
||||
}
|
||||
|
||||
|
||||
def _rehydrate_mcp_servers_payload(incoming: Any, current: Any) -> list:
|
||||
if not isinstance(incoming, list):
|
||||
return []
|
||||
|
|
@ -435,7 +487,7 @@ def _api_owner_runtime_mode_sync(request: Request, body: Any) -> JSONResponse:
|
|||
|
||||
raw_mode = str((body or {}).get("mode") or "").strip().lower()
|
||||
if raw_mode not in set(_config.VALID_RUNTIME_MODES):
|
||||
return unsaved_error("'mode' must be one of: light, advanced, pro", 400)
|
||||
return unsaved_error("'mode' must be one of: light, advanced, pro, cyber_pro", 400)
|
||||
# The digest is taken BEFORE the read that decides, so a write landing between the
|
||||
# two is refused rather than silently reverted by this request's write.
|
||||
digest = settings_document_digest()
|
||||
|
|
@ -999,6 +1051,19 @@ async def api_settings_get(request: Request) -> JSONResponse:
|
|||
except (ValueError, OSError):
|
||||
port = _default_port(request)
|
||||
meta = _build_network_meta(_current_bind_host(request), port)
|
||||
# Keep the three owner-facing policy axes honest after reload. The values
|
||||
# on the document are the pending/configured choices; process state is the
|
||||
# effective value this server can currently report. Runtime access is
|
||||
# restart-bound, while Supervisor and Review are picked up for new tasks by
|
||||
# the existing settings effect path. This is presentation metadata only,
|
||||
# not a second policy store.
|
||||
try:
|
||||
meta["policy_state"] = _build_policy_state(settings)
|
||||
except Exception:
|
||||
# A settings read must stay available even if an optional projection
|
||||
# helper is unavailable during startup. The persisted values remain
|
||||
# the ordinary response fields and are still masked below.
|
||||
log.debug("Could not build settings policy-state projection", exc_info=True)
|
||||
meta["custom_secret_keys"] = sorted(
|
||||
key for key in settings
|
||||
if key not in SECRET_SETTING_KEYS
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ def _create_task_from_body(request: Request, body: Any) -> JSONResponse:
|
|||
if task_type in {"evolution", "review", "deep_self_review"}:
|
||||
return json_error(
|
||||
f"task type {task_type!r} is internal-only and cannot be created via the task API "
|
||||
"(use /evolve or /review); evolution additionally requires advanced/pro runtime mode",
|
||||
"(use /evolve or /review); evolution additionally requires advanced/pro/cyber_pro runtime mode",
|
||||
400,
|
||||
)
|
||||
if workspace_root and task_type != "task":
|
||||
|
|
|
|||
|
|
@ -130,8 +130,19 @@ def _git_config_readonly(args: list[str]) -> bool:
|
|||
_GH_AUTH_MUTATING_VERBS = frozenset({"login", "logout", "refresh", "switch", "setup-git"})
|
||||
|
||||
|
||||
def gh_shell_block_reason(raw_cmd: Any) -> str:
|
||||
"""Positional gh policy: judged only where `gh` is a segment's command head."""
|
||||
def gh_shell_block_reason(raw_cmd: Any, *, runtime_mode: str = "") -> str:
|
||||
"""Positional gh policy, with Cyber Pro owner-authority escape.
|
||||
|
||||
The argv/segment parser remains the source of the ordinary-mode policy.
|
||||
Cyber Pro is the explicit owner-selected mode that permits technical
|
||||
authentication and repository setup attempts; the tool's factual result
|
||||
(including provider/OS failure) is still returned unchanged.
|
||||
"""
|
||||
if runtime_mode:
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(runtime_mode, "cyber_pro"):
|
||||
return ""
|
||||
for segment in shell_segments(raw_cmd):
|
||||
_env, command = collect_leading_env(segment)
|
||||
if not command:
|
||||
|
|
@ -139,7 +150,7 @@ def gh_shell_block_reason(raw_cmd: Any) -> str:
|
|||
head = pathlib.PurePath(str(command[0])).name.lower()
|
||||
if head in {"bash", "sh", "zsh"}:
|
||||
inline = shell_command_string(command)
|
||||
if inline and (nested := gh_shell_block_reason(inline)):
|
||||
if inline and (nested := gh_shell_block_reason(inline, runtime_mode=runtime_mode)):
|
||||
return nested
|
||||
continue
|
||||
if head != "gh":
|
||||
|
|
|
|||
|
|
@ -565,6 +565,7 @@ ACCEPTANCE_DECISION_REASONS = (
|
|||
"review_degraded",
|
||||
"fence_reopen_failed",
|
||||
"infra_failure",
|
||||
"author_finish",
|
||||
# The pacing/wallet reason two branches below already STAMP (`pass_reason ==
|
||||
# REASON_REVIEW_CYCLES_EXHAUSTED`); it was missing from the closed set, so a
|
||||
# spent shared cap shipped a reason no reader could validate.
|
||||
|
|
|
|||
|
|
@ -593,6 +593,62 @@ def _apply_task_acceptance_result(
|
|||
ctx.emit_progress("Task acceptance review: PASS (clean acceptance).")
|
||||
return False
|
||||
|
||||
# Advisory author-finality: the first host panel still runs and its raw
|
||||
# findings stay durable, but an explicit author stance ends the dialogue
|
||||
# without forcing the improvement capsule through another reviewer round.
|
||||
# This never mints PASS and is deliberately unavailable to Blocking, where
|
||||
# the selected gate remains the authority.
|
||||
author_stance = (
|
||||
ctx.llm_trace.get("acceptance_decision", {})
|
||||
if isinstance(ctx.llm_trace.get("acceptance_decision"), dict) else {}
|
||||
)
|
||||
author_disposition = str(author_stance.get("agent_disposition") or "").strip().lower()
|
||||
if (
|
||||
_loop().get_review_enforcement() == "advisory"
|
||||
and author_disposition in {"accepted", "rejected", "partial", "deferred"}
|
||||
):
|
||||
from ouroboros.review_records import build_author_disposition
|
||||
try:
|
||||
author_record = build_author_disposition(
|
||||
disposition=author_disposition,
|
||||
rationale=str(author_stance.get("agent_rationale") or "") or "Author finished the advisory review.",
|
||||
subject_hash=str(
|
||||
ctx.review_binding.get("binding_hash")
|
||||
or ctx.review_binding.get("candidate_hash")
|
||||
or ctx.content
|
||||
),
|
||||
reviewer_signal=str(result.aggregate_signal or "DEGRADED").upper(),
|
||||
enforcement="advisory",
|
||||
)
|
||||
except ValueError:
|
||||
author_record = {}
|
||||
ctx.tools._ctx._task_acceptance_reviewed = True
|
||||
_loop()._end_task_acceptance_fence(ctx.tools._ctx, outcome="terminal")
|
||||
_loop()._mark_root_acceptance_checkpoint(
|
||||
ctx.tools._ctx,
|
||||
ctx.llm_trace,
|
||||
status=str(result.aggregate_signal or "DEGRADED").lower(),
|
||||
pass_index=ctx.passes_done,
|
||||
)
|
||||
_loop()._set_acceptance_decision(ctx.llm_trace, {
|
||||
"status": ACCEPTANCE_FINALIZED_UNACCEPTED,
|
||||
"reason": "author_finish",
|
||||
"source": "task_acceptance_review",
|
||||
"rationale": (
|
||||
"The author explicitly finished the advisory acceptance dialogue; "
|
||||
"raw reviewer findings remain recorded and no reviewer PASS was fabricated."
|
||||
),
|
||||
"author_disposition": author_record or author_disposition,
|
||||
"author_rationale": str(author_stance.get("agent_rationale") or ""),
|
||||
"reviewer_signal": str(result.aggregate_signal or "DEGRADED").upper(),
|
||||
"dissent_noted": bool(dissent),
|
||||
})
|
||||
ctx.emit_progress(
|
||||
f"Task acceptance review: {result.aggregate_signal} — author finished advisory review "
|
||||
f"({author_disposition}); raw findings retained."
|
||||
)
|
||||
return False
|
||||
|
||||
if reused:
|
||||
return _refuse_identical_acceptance(
|
||||
ctx, result,
|
||||
|
|
|
|||
|
|
@ -620,6 +620,14 @@ def _acceptance_decision_projection(acceptance_decision: Dict[str, Any]) -> Dict
|
|||
"agent_disposition": str(acceptance_decision.get("agent_disposition") or ""),
|
||||
"agent_rationale": str(acceptance_decision.get("agent_rationale") or "")[:500],
|
||||
}
|
||||
if acceptance_decision.get("reason") == "author_finish":
|
||||
record = acceptance_decision.get("author_disposition")
|
||||
if isinstance(record, dict):
|
||||
out["author_disposition"] = dict(record)
|
||||
else:
|
||||
out["author_disposition"] = str(record or "")
|
||||
out["author_rationale"] = str(acceptance_decision.get("author_rationale") or "")[:500]
|
||||
out["reviewer_signal"] = str(acceptance_decision.get("reviewer_signal") or "")
|
||||
# v6.54.4: dissent + obligations transparency (blocking review policy).
|
||||
if acceptance_decision.get("dissent_noted"):
|
||||
out["dissent_noted"] = True
|
||||
|
|
|
|||
|
|
@ -17,6 +17,94 @@ from typing import Any, Dict, List, Optional
|
|||
from ouroboros.review_execution import ReviewRouteKind, delivery_retrieves
|
||||
|
||||
|
||||
# One semantic author-finality record shared by review owners. Surfaces keep
|
||||
# their existing storage and reviewer evidence; this vocabulary only makes an
|
||||
# author's final stance explicit and hash-bound when a review is advisory.
|
||||
AUTHOR_DISPOSITION_VALUES = frozenset({"accepted", "rejected", "partial", "deferred"})
|
||||
|
||||
|
||||
def build_author_disposition(
|
||||
*,
|
||||
disposition: str,
|
||||
rationale: str,
|
||||
subject_hash: str,
|
||||
reviewer_signal: str = "",
|
||||
enforcement: str = "",
|
||||
source: str = "author",
|
||||
recorded_at: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Build one bounded, current-subject author-finality record.
|
||||
|
||||
This is a record helper, not a second review ledger. Callers persist the
|
||||
returned object in their existing plan/skill/acceptance/commit owners and
|
||||
continue to retain raw reviewer rows beside it. A missing hash or reason
|
||||
is rejected so an author finish can never look like an unbound PASS.
|
||||
"""
|
||||
value = str(disposition or "").strip().lower()
|
||||
reason = " ".join(str(rationale or "").split()).strip()
|
||||
subject = str(subject_hash or "").strip()
|
||||
if value not in AUTHOR_DISPOSITION_VALUES:
|
||||
raise ValueError("AUTHOR_DISPOSITION_INVALID: unknown disposition")
|
||||
if not subject:
|
||||
raise ValueError("AUTHOR_DISPOSITION_INVALID: subject_hash is required")
|
||||
if not reason:
|
||||
raise ValueError("AUTHOR_DISPOSITION_INVALID: rationale is required")
|
||||
if len(reason) > 8_000:
|
||||
raise ValueError("AUTHOR_DISPOSITION_INVALID: rationale is too large")
|
||||
if not recorded_at:
|
||||
from ouroboros.utils import utc_now_iso
|
||||
|
||||
recorded_at = utc_now_iso()
|
||||
return {
|
||||
"disposition": value,
|
||||
"rationale": reason,
|
||||
"subject_hash": subject,
|
||||
"reviewer_signal": str(reviewer_signal or "").strip(),
|
||||
"enforcement": str(enforcement or "").strip().lower(),
|
||||
"recorded_at": str(recorded_at),
|
||||
"source": str(source or "author"),
|
||||
}
|
||||
|
||||
|
||||
def validate_author_disposition(
|
||||
record: Any,
|
||||
*,
|
||||
subject_hash: str = "",
|
||||
allow_stale: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Validate and return a safe copy, rejecting malformed or stale records."""
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
try:
|
||||
normalized = build_author_disposition(
|
||||
disposition=record.get("disposition", ""),
|
||||
rationale=record.get("rationale", ""),
|
||||
subject_hash=record.get("subject_hash", ""),
|
||||
reviewer_signal=record.get("reviewer_signal", ""),
|
||||
enforcement=record.get("enforcement", ""),
|
||||
source=record.get("source", "author"),
|
||||
recorded_at=record.get("recorded_at", ""),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
expected = str(subject_hash or "").strip()
|
||||
if expected and normalized["subject_hash"] != expected and not allow_stale:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def build_author_disposition_from_mapping(
|
||||
value: Any, *, subject_hash: str, reviewer_signal: str = "", enforcement: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Parse the public two-field author finish envelope."""
|
||||
if not isinstance(value, dict) or set(value) - {"disposition", "rationale"}:
|
||||
raise ValueError("AUTHOR_DISPOSITION_INVALID: envelope fields are invalid")
|
||||
return build_author_disposition(
|
||||
disposition=value.get("disposition", ""), rationale=value.get("rationale", ""),
|
||||
subject_hash=subject_hash, reviewer_signal=reviewer_signal, enforcement=enforcement,
|
||||
)
|
||||
|
||||
|
||||
def apply_review_model_override(slot: Any, overrides: Dict[str, dict], *, slot_id: str = "") -> Any:
|
||||
"""Project an explicit owner model choice onto one frozen reviewer row.
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ def _commit_attempt_from_dict(d: Dict[str, Any]) -> CommitAttemptRecord:
|
|||
else {} if raw_scope is None
|
||||
else {"raw_results": [_malformed_roster_row("scope_review")]}
|
||||
),
|
||||
author_disposition=(dict(d.get("author_disposition"))
|
||||
if isinstance(d.get("author_disposition"), dict) else {}),
|
||||
paid=bool(d.get("paid", False)),
|
||||
review_owner_pid=_coerce_int(d.get("review_owner_pid", 0)),
|
||||
raw_stripped=bool(d.get("raw_stripped", False)),
|
||||
|
|
@ -329,6 +331,12 @@ def _save_state_unlocked(drive_root: pathlib.Path, state: AdvisoryReviewState) -
|
|||
path = drive_root / _STATE_RELPATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_prepare_state_for_persistence(state)
|
||||
# Legacy/in-memory callers may construct pre-author-disposition
|
||||
# CommitAttemptRecord objects directly. Normalize the additive field before
|
||||
# dataclasses.asdict so persistence remains backward compatible.
|
||||
for attempt in state.attempts:
|
||||
if not hasattr(attempt, "author_disposition"):
|
||||
setattr(attempt, "author_disposition", {})
|
||||
data: Dict[str, Any] = {
|
||||
"state_version": _STATE_SCHEMA_VERSION,
|
||||
"schema_version": _STATE_SCHEMA_VERSION,
|
||||
|
|
|
|||
|
|
@ -341,6 +341,9 @@ class CommitAttemptRecord:
|
|||
# free text) were compacted because the preserved accounting row fell
|
||||
# outside the newest-50 ledger window (see _strip_attempt_heavy_payload).
|
||||
raw_stripped: bool = False
|
||||
# Optional canonical author-finish stance for an advisory commit. Raw
|
||||
# reviewer evidence remains in the same attempt row beside this record.
|
||||
author_disposition: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _attempt_identity_tuple(attempt: CommitAttemptRecord) -> tuple[str, str, str, str]:
|
||||
|
|
|
|||
|
|
@ -457,13 +457,15 @@ def build_improvement_capsule(
|
|||
# improves the result; otherwise produce your normal final answer" tail
|
||||
# was the measured cause of the do-nothing resubmit loop (SWE 1b311217:
|
||||
# 7 passes, zero tool calls). The anti-derailment guards stay verbatim.
|
||||
"Three real moves are available: (1) FIX — change the work/answer so the next panel is "
|
||||
"Four real moves are available: (1) FIX — change the work/answer so the next panel is "
|
||||
"clean; (2) REBUT — file obligation_dispositions (rejected + your reason) via the "
|
||||
"task_acceptance_review tool for findings you can show are wrong; the reviewer "
|
||||
"adjudicates the argument; (3) DECLARE UNREACHABLE — dispose an obligation as "
|
||||
"unsatisfiable in this environment (rejected + the concrete gap), and the reviewer "
|
||||
"judges reachability. Resubmitting the same answer with none of these moves changes "
|
||||
"nothing. "
|
||||
"nothing. (4) AUTHOR FINISH — under advisory enforcement, record accepted, rejected, "
|
||||
"partial, or deferred with a rationale; the first panel's raw findings remain durable, "
|
||||
"no reviewer PASS is fabricated, and Blocking enforcement still requires its own gate. "
|
||||
"Do not mention this review or the reviewer unless the user asked. "
|
||||
"The assessment tier above is an internal ledger label — never emit an internal ledger "
|
||||
"identifier as the deliverable itself."
|
||||
|
|
|
|||
|
|
@ -8,10 +8,200 @@ triad + scope review gate.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from ouroboros.settings_scales import _RUNTIME_MODE_RANK
|
||||
|
||||
|
||||
def runtime_mode_rank(runtime_mode: str) -> int:
|
||||
"""Return the ordered runtime-mode rank without duplicating the vocabulary.
|
||||
|
||||
``settings_scales`` is the owner of the persisted enum and rank. Unknown
|
||||
values remain below every known mode.
|
||||
"""
|
||||
return int(_RUNTIME_MODE_RANK.get(str(runtime_mode or "").strip().lower(), -1))
|
||||
|
||||
|
||||
def runtime_mode_at_least(runtime_mode: str, minimum: str) -> bool:
|
||||
"""Whether ``runtime_mode`` meets the named ordered capability floor."""
|
||||
mode_rank = runtime_mode_rank(runtime_mode)
|
||||
minimum_rank = runtime_mode_rank(minimum)
|
||||
return mode_rank >= 0 and minimum_rank >= 0 and mode_rank >= minimum_rank
|
||||
|
||||
|
||||
def protected_bible_history_delete_reason(
|
||||
raw_cmd: object, *, extra_paths: Iterable[str] = (),
|
||||
protect_bible: bool = True, identity_path: pathlib.Path | None = None,
|
||||
cwd: pathlib.Path | None = None,
|
||||
) -> str:
|
||||
"""Return a refusal for physical BIBLE deletion or repository history rewrites.
|
||||
|
||||
This is deliberately a small argv/verb predicate at the existing shell
|
||||
guard seam. It does not classify arbitrary content or restrict ordinary
|
||||
``rm`` commands elsewhere.
|
||||
"""
|
||||
try:
|
||||
from ouroboros.shell_parse import collect_leading_env, shell_segments
|
||||
|
||||
delete_heads = {"rm", "unlink", "mv"}
|
||||
history_verbs = {
|
||||
"filter-branch", "filter-repo", "checkout", "restore", "read-tree",
|
||||
"update-index", "reset", "commit", "rebase", "replace",
|
||||
}
|
||||
|
||||
def _protected_target(candidate: str) -> bool:
|
||||
path = pathlib.Path(candidate.replace("\\", "/"))
|
||||
if protect_bible and path.name.casefold() == "bible.md":
|
||||
return True
|
||||
return bool(identity_path is not None and (
|
||||
(cwd or pathlib.Path.cwd()) / path
|
||||
).resolve(strict=False) == identity_path.resolve(strict=False))
|
||||
|
||||
def _bible_path(
|
||||
words: list[str], *, path_flag_only: bool = False,
|
||||
extra: Iterable[str] = (),
|
||||
) -> bool:
|
||||
"""Recognize an explicit BIBLE.md path, including --path= forms."""
|
||||
candidates: list[str] = []
|
||||
expect_value = False
|
||||
for word in words:
|
||||
token = str(word).strip("'\"")
|
||||
if expect_value:
|
||||
candidates.append(token)
|
||||
expect_value = False
|
||||
continue
|
||||
if token in {"--path", "--path-file", "--paths"}:
|
||||
expect_value = True
|
||||
continue
|
||||
if token.startswith("--path="):
|
||||
candidates.append(token.split("=", 1)[1])
|
||||
continue
|
||||
if not path_flag_only:
|
||||
candidates.append(token)
|
||||
return any(
|
||||
_protected_target(candidate)
|
||||
for candidate in (*candidates, *(str(path) for path in (*extra_paths, *extra)))
|
||||
)
|
||||
|
||||
def _python_delete_paths(body: str) -> list[str]:
|
||||
"""Extract literal targets of structural Python deletion calls."""
|
||||
try:
|
||||
from ouroboros.tools.shell_guards import python_body_ast
|
||||
|
||||
tree = python_body_ast(body)
|
||||
if tree is None:
|
||||
return []
|
||||
found: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
deletion = False
|
||||
if isinstance(func, ast.Attribute):
|
||||
attr = str(func.attr or "")
|
||||
receiver = func.value
|
||||
if attr in {"remove", "unlink", "rmtree", "removedirs"}:
|
||||
deletion = (
|
||||
isinstance(receiver, ast.Name)
|
||||
and receiver.id in {"os", "shutil"}
|
||||
) or (
|
||||
isinstance(receiver, ast.Call)
|
||||
and isinstance(receiver.func, ast.Name)
|
||||
and receiver.func.id in {"Path", "PurePath"}
|
||||
)
|
||||
if attr in {"run", "call", "check_call", "check_output", "Popen"}:
|
||||
deletion = any(
|
||||
isinstance(item, ast.Constant)
|
||||
and str(item.value).strip().lower() in {"rm", "unlink"}
|
||||
for item in ast.walk(node)
|
||||
)
|
||||
for item in ast.walk(node):
|
||||
if not isinstance(item, ast.Constant) or not isinstance(item.value, str):
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(item.value)
|
||||
except ValueError:
|
||||
continue
|
||||
if tokens and pathlib.PurePath(tokens[0]).name.lower() in {"rm", "unlink", "mv"}:
|
||||
deletion = True
|
||||
found.extend(tokens[1:])
|
||||
if isinstance(func, ast.Name) and func.id in {"remove", "unlink"}:
|
||||
deletion = True
|
||||
if deletion:
|
||||
found.extend(
|
||||
str(item.value)
|
||||
for item in ast.walk(node)
|
||||
if isinstance(item, ast.Constant)
|
||||
and isinstance(item.value, str)
|
||||
)
|
||||
return found
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
for segment in shell_segments(raw_cmd):
|
||||
_env, argv = collect_leading_env(segment)
|
||||
if not argv:
|
||||
continue
|
||||
head = pathlib.PurePath(str(argv[0])).name.lower().removesuffix(".exe")
|
||||
words = [str(item).replace("\\", "/") for item in argv[1:]]
|
||||
if head in {"sh", "bash", "zsh"}:
|
||||
nested = ""
|
||||
for index, word in enumerate(words[:-1]):
|
||||
if word in {"-c", "--command"}:
|
||||
nested = words[index + 1]
|
||||
break
|
||||
if nested:
|
||||
nested_reason = protected_bible_history_delete_reason(
|
||||
nested, extra_paths=extra_paths, protect_bible=protect_bible,
|
||||
identity_path=identity_path, cwd=cwd,
|
||||
)
|
||||
if nested_reason:
|
||||
return nested_reason
|
||||
continue
|
||||
bible = _bible_path(words)
|
||||
if head in delete_heads and bible:
|
||||
label = "IDENTITY" if any(
|
||||
pathlib.PurePath(word.strip("'\"")).name.casefold() == "identity.md"
|
||||
for word in words
|
||||
) else "BIBLE"
|
||||
return f"{label}_DELETE_BLOCKED: protected identity history must remain physically present."
|
||||
if head == "git":
|
||||
verbs = [word.lower() for word in words if not word.startswith("-")]
|
||||
if verbs and verbs[0] in {"rm", "mv"} and bible:
|
||||
label = "IDENTITY" if any(
|
||||
pathlib.PurePath(word.strip("'\"")).name.casefold() == "identity.md"
|
||||
for word in words
|
||||
) else "BIBLE"
|
||||
return f"{label}_DELETE_BLOCKED: git rm/git mv cannot remove or rename protected identity files."
|
||||
if verbs and verbs[0] in history_verbs and _bible_path(
|
||||
words, path_flag_only=(verbs[0] in {"filter-branch", "filter-repo"})
|
||||
):
|
||||
return "BIBLE_HISTORY_REWRITE_BLOCKED: BIBLE history must remain physically recoverable."
|
||||
head_name = pathlib.PurePath(str(argv[0])).name.lower().removesuffix(".exe")
|
||||
if head_name.startswith(("python", "python3")):
|
||||
try:
|
||||
from ouroboros.tools.shell_guards import interpreter_inline_code
|
||||
|
||||
for body in interpreter_inline_code([str(item) for item in argv]):
|
||||
deleted = _python_delete_paths(body)
|
||||
if any(
|
||||
_protected_target(str(path))
|
||||
for path in deleted
|
||||
):
|
||||
target = "identity.md" if any(
|
||||
pathlib.PurePath(path).name.casefold() == "identity.md" for path in deleted
|
||||
) else "bible.md"
|
||||
return f"{target.upper().replace('.MD', '')}_DELETE_BLOCKED: protected identity history must remain physically present."
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
SAFETY_CRITICAL_PATHS = frozenset({
|
||||
"BIBLE.md",
|
||||
|
|
@ -168,7 +358,7 @@ def protected_paths_in(paths: Iterable[str]) -> list[ProtectedPath]:
|
|||
|
||||
|
||||
def mode_allows_protected_write(runtime_mode: str) -> bool:
|
||||
return str(runtime_mode or "").strip().lower() == "pro"
|
||||
return runtime_mode_at_least(runtime_mode, "pro")
|
||||
|
||||
|
||||
def format_protected_paths(paths: Iterable[ProtectedPath | str]) -> str:
|
||||
|
|
@ -193,17 +383,18 @@ def protected_write_block_message(
|
|||
) -> str:
|
||||
norm = normalize_repo_path(path)
|
||||
category = protected_path_category(norm)
|
||||
target_modes = "runtime_mode='pro' or 'cyber_pro'" if str(runtime_mode).strip().lower() == "cyber_pro" else "runtime_mode='pro'"
|
||||
return (
|
||||
f"⚠️ CORE_PROTECTION_BLOCKED: runtime_mode={runtime_mode!r} refuses "
|
||||
f"to {action} protected {category or 'core'} path: {norm}. "
|
||||
"Switch to runtime_mode='pro' and let the normal triad + scope review "
|
||||
f"Switch to {target_modes} and let the normal triad + scope review "
|
||||
"cover the protected core/contract/release change before commit."
|
||||
)
|
||||
|
||||
|
||||
def core_patch_notice(paths: Iterable[ProtectedPath | str]) -> str:
|
||||
return (
|
||||
"⚠️ CORE_PATCH_NOTICE: runtime_mode='pro' is editing protected "
|
||||
"⚠️ CORE_PATCH_NOTICE: runtime_mode='pro' or 'cyber_pro' is editing protected "
|
||||
"Ouroboros core/contract/release surface(s): "
|
||||
f"{format_protected_paths(paths)}. These changes can be committed only "
|
||||
"through the normal triad + scope review pipeline."
|
||||
|
|
|
|||
|
|
@ -84,11 +84,13 @@ def resolve_prompt_cache_ttl() -> str:
|
|||
return raw if raw in PROMPT_CACHE_TTL_SCALE else default
|
||||
|
||||
|
||||
# Runtime mode and review enforcement are separate axes.
|
||||
VALID_RUNTIME_MODES = ("light", "advanced", "pro")
|
||||
# Runtime mode and review enforcement are separate axes. ``cyber_pro`` is the
|
||||
# owner-selected high-power access level; it remains an ordinary member of the
|
||||
# same closed scale so every consumer shares one vocabulary and rank.
|
||||
VALID_RUNTIME_MODES = ("light", "advanced", "pro", "cyber_pro")
|
||||
|
||||
# Lower rank = stricter scope. ``save_settings`` refuses agent self-elevation.
|
||||
_RUNTIME_MODE_RANK = {"light": 0, "advanced": 1, "pro": 2}
|
||||
_RUNTIME_MODE_RANK = {"light": 0, "advanced": 1, "pro": 2, "cyber_pro": 3}
|
||||
|
||||
|
||||
def normalize_runtime_mode(value: Any) -> str:
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ _RUNTIME_MODES = _rows(("value", "label", "tone", "className", "copy"), (
|
|||
("light", "Light", "Safest", "light", "Self-modification of the main repo is disabled. Best for trying Ouroboros out without repo self-modification."),
|
||||
("advanced", "Advanced", "Default", "advanced", "Self-modification of the evolutionary layer is allowed (current behaviour). Protected core/contract/release files stay guarded by Advanced mode."),
|
||||
("pro", "Pro", "Power", "pro", "Direct protected-surface mode. Protected core/contract/release edits are allowed on disk, but commits still require the normal triad + scope review gate."),
|
||||
("cyber_pro", "Cyber Pro", "Maximum power", "cyber-pro", "Full host and configuration authority, including credentials, policy settings, and protected rewrites. Blocking or Advisory review remains your separate choice."),
|
||||
))
|
||||
|
||||
_LOCAL_ROUTING_MODES = _rows(("value", "buttonLabel", "label", "flags"), (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from ouroboros.contracts.plugin_api import FORBIDDEN_SKILL_SETTINGS
|
|||
from ouroboros.contracts.schema_versions import with_schema_version
|
||||
from ouroboros.skill_review_status import STATUS_BLOCKERS, STATUS_CLEAN, STATUS_PENDING, STATUS_WARNINGS, VALID_SKILL_REVIEW_STATUSES, aggregate_skill_review_status, normalize_skill_review_status, skill_review_gate
|
||||
from ouroboros.utils import append_jsonl, atomic_write_json, read_json_dict, utc_now_iso
|
||||
from ouroboros.review_records import validate_author_disposition
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -78,6 +79,11 @@ class SkillReviewState:
|
|||
raw_actor_records: List[Dict[str, Any]] = field(default_factory=list)
|
||||
advisory_result: Dict[str, Any] = field(default_factory=dict)
|
||||
review_profile: str = ""
|
||||
# Optional author-finality record for an advisory review. It is bound to
|
||||
# content_hash and never changes the raw reviewer findings or deterministic
|
||||
# preflight status.
|
||||
author_disposition: Dict[str, Any] = field(default_factory=dict)
|
||||
reviewed_content_hash: str = ""
|
||||
|
||||
def is_stale_for(self, current_hash: str) -> bool:
|
||||
if not current_hash:
|
||||
|
|
@ -101,6 +107,10 @@ class SkillReviewState:
|
|||
data["review_profile"] = str(self.review_profile)
|
||||
if self.advisory_result:
|
||||
data["advisory_result"] = dict(self.advisory_result)
|
||||
if self.author_disposition:
|
||||
data["author_disposition"] = dict(self.author_disposition)
|
||||
if self.reviewed_content_hash:
|
||||
data["reviewed_content_hash"] = str(self.reviewed_content_hash)
|
||||
has_review_verdicts = any(
|
||||
str(f.get("verdict") or "").upper() in {"PASS", "FAIL"}
|
||||
for f in self.findings
|
||||
|
|
@ -623,6 +633,10 @@ def load_review_state(
|
|||
if isinstance(data.get("advisory_result"), dict)
|
||||
else {}
|
||||
)
|
||||
author_disposition = validate_author_disposition(
|
||||
data.get("author_disposition"),
|
||||
subject_hash=str(data.get("content_hash") or ""),
|
||||
) or {}
|
||||
try:
|
||||
prompt_chars = int(data.get("prompt_chars") or 0)
|
||||
except (TypeError, ValueError):
|
||||
|
|
@ -643,6 +657,8 @@ def load_review_state(
|
|||
raw_actor_records=[r for r in raw_actor_records if isinstance(r, dict)],
|
||||
advisory_result=dict(advisory_result),
|
||||
review_profile=review_profile,
|
||||
author_disposition=author_disposition,
|
||||
reviewed_content_hash=str(data.get("reviewed_content_hash") or ""),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@ Skill Review Checklist items permit the behaviour in isolation. Treat
|
|||
BIBLE.md as the tie-breaker when a skill looks checklist-compliant but
|
||||
contradicts the runtime's constitutional commitments.
|
||||
|
||||
The author may finish an advisory review for the exact current content hash.
|
||||
That author disposition is a separate durable stance beside these raw findings;
|
||||
it is never a reviewer PASS, never valid for stale content, and never bypasses
|
||||
deterministic preflight or a blocking enforcement gate.
|
||||
|
||||
{bible_text}
|
||||
|
||||
{skill_host_context}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from ouroboros.cost_projection import (
|
|||
normalize_task_result_cost_planes,
|
||||
)
|
||||
from ouroboros.utils import read_json_dict, update_json_locked, utc_now_iso
|
||||
from ouroboros.review_records import validate_author_disposition
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1074,6 +1075,13 @@ def _validated_plan_review_state(value: Any) -> Dict[str, Any]:
|
|||
raise ValueError("PLAN_REVIEW_STATE_INVALID: full wave needs spec and findings")
|
||||
if not isinstance(wave.get("dispositions", []), list):
|
||||
raise ValueError("PLAN_REVIEW_STATE_INVALID: dispositions must be a list")
|
||||
if "author_disposition" in wave:
|
||||
author = validate_author_disposition(
|
||||
wave.get("author_disposition"),
|
||||
subject_hash=fingerprint,
|
||||
)
|
||||
if author is None:
|
||||
raise ValueError("PLAN_REVIEW_STATE_INVALID: author_disposition is malformed or stale")
|
||||
seen.add(fingerprint)
|
||||
cycles_paid = value.get("cycles_paid", 0)
|
||||
if not isinstance(cycles_paid, int) or isinstance(cycles_paid, bool) or cycles_paid < 0:
|
||||
|
|
@ -1415,6 +1423,8 @@ def _compact_plan_review_wave(wave: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"closed": bool(wave.get("closed")),
|
||||
"paid": bool(wave.get("paid")),
|
||||
"wave_artifact": copy.deepcopy(wave.get("wave_artifact") or {}),
|
||||
**({"author_disposition": copy.deepcopy(wave["author_disposition"])}
|
||||
if isinstance(wave.get("author_disposition"), dict) else {}),
|
||||
**({"spec_source_ref": copy.deepcopy(wave["spec_source_ref"])} if wave.get("spec_source_ref") else {}),
|
||||
**({"reviewed_at": str(wave["reviewed_at"])} if wave.get("reviewed_at") else {}),
|
||||
}
|
||||
|
|
@ -1557,6 +1567,7 @@ def record_plan_review_dispositions(
|
|||
closure_notes: Optional[List[str]] = None,
|
||||
wave_artifact: Optional[Dict[str, Any]] = None,
|
||||
recorded_at: str = "",
|
||||
author_disposition: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Store the agent's dispositions on one FULL wave and its resulting closure.
|
||||
Only note-only closed waves accept annotations. Closure authority remains
|
||||
|
|
@ -1577,6 +1588,14 @@ def record_plan_review_dispositions(
|
|||
wave["closure_notes"] = list(closure_notes)
|
||||
if wave_artifact is not None:
|
||||
wave["wave_artifact"] = copy.deepcopy(wave_artifact)
|
||||
if author_disposition is not None:
|
||||
author = validate_author_disposition(
|
||||
author_disposition,
|
||||
subject_hash=fingerprint,
|
||||
)
|
||||
if author is None:
|
||||
raise ValueError("PLAN_REVIEW_AUTHOR_DISPOSITION_INVALID: stale or malformed record")
|
||||
wave["author_disposition"] = author
|
||||
if closed and str(wave.get("aggregate") or "") == "REVIEW_REQUIRED":
|
||||
wave["closed"] = True
|
||||
state["current_attempt"] = {"fingerprint": fingerprint, "status": "open", "reason": ""}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ def summarize_subagent_profile(profile: ToolProfile, *, effective_lane: str = ""
|
|||
at schedule time (and the child sees first line of its context) what the child
|
||||
CAN and CANNOT do. Prevents the wasted rounds where a prober child hit
|
||||
workspace_blocked on run_script because neither side knew shell was off."""
|
||||
matrix = _POLICY.get(profile, {})
|
||||
matrix = _POLICY.get(_effective_policy_profile(profile), {})
|
||||
shell_roots = sorted(root for root, ops in matrix.items() if "shell" in ops)
|
||||
write_roots = sorted(root for root, ops in matrix.items() if ops & {"write", "edit"})
|
||||
has_shell = bool(shell_roots)
|
||||
|
|
@ -105,20 +105,36 @@ def summarize_subagent_profile(profile: ToolProfile, *, effective_lane: str = ""
|
|||
return "child capabilities — " + " · ".join(bits)
|
||||
|
||||
|
||||
def _effective_policy_profile(profile: ToolProfile) -> ToolProfile:
|
||||
"""Map an acting child to the existing full matrix only in Cyber Pro."""
|
||||
if profile != "acting_subagent":
|
||||
return profile
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(get_runtime_mode(), "cyber_pro"):
|
||||
return "operator_control"
|
||||
except Exception:
|
||||
pass
|
||||
return profile
|
||||
|
||||
|
||||
def decide_tool_access(
|
||||
*,
|
||||
profile: ToolProfile,
|
||||
root: ResourceRoot,
|
||||
operation: Operation,
|
||||
) -> ToolAccessDecision:
|
||||
allowed = operation in _POLICY.get(profile, {}).get(root, set())
|
||||
effective_profile = _effective_policy_profile(profile)
|
||||
allowed = operation in _POLICY.get(effective_profile, {}).get(root, set())
|
||||
if allowed:
|
||||
return ToolAccessDecision(True, guard=f"{profile}:{root}:{operation}")
|
||||
allowed_roots = ", ".join(sorted(r for r, ops in _POLICY.get(profile, {}).items() if operation in ops)) or "(none)"
|
||||
return ToolAccessDecision(True, guard=f"{effective_profile}:{root}:{operation}")
|
||||
allowed_roots = ", ".join(sorted(r for r, ops in _POLICY.get(effective_profile, {}).items() if operation in ops)) or "(none)"
|
||||
return ToolAccessDecision(
|
||||
False,
|
||||
reason=f"profile={profile} cannot {operation} root={root}. Roots your profile can {operation}: {allowed_roots}.",
|
||||
guard=f"{profile}:{root}:{operation}",
|
||||
reason=f"profile={effective_profile} cannot {operation} root={root}. Roots your profile can {operation}: {allowed_roots}.",
|
||||
guard=f"{effective_profile}:{root}:{operation}",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -244,7 +260,7 @@ def filesystem_affordance_map(ctx: Any, *, runtime_mode: str = "") -> dict[str,
|
|||
"""
|
||||
|
||||
profile = active_tool_profile(ctx)
|
||||
policy = _POLICY.get(profile, {})
|
||||
policy = _POLICY.get(_effective_policy_profile(profile), {})
|
||||
# H2 (capinv-447): a root is writable iff a MUTATING operation is granted on
|
||||
# it. Grouping "vcs" as write-like claimed writable roots for the read-only
|
||||
# child profile (status/diff-only vcs), contradicting summarize_subagent_profile.
|
||||
|
|
|
|||
|
|
@ -181,6 +181,18 @@ def user_files_path_block_reason(
|
|||
# name shapes are never consulted here, so this branch must stay free
|
||||
# of any credential_shapes import (import-boundary test).
|
||||
return ""
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
from ouroboros.tool_access import active_tool_profile
|
||||
|
||||
# Cyber Pro is the explicit owner authority for credential-file
|
||||
# mutation. A deliberately readonly child remains excluded; acting
|
||||
# children inherit the same authority through the existing profile.
|
||||
if runtime_mode_at_least(get_runtime_mode(), "cyber_pro") and active_tool_profile(ctx) != "local_readonly_subagent":
|
||||
return ""
|
||||
except Exception:
|
||||
pass
|
||||
from ouroboros.credential_shapes import user_files_mutation_shape_reason
|
||||
|
||||
return user_files_mutation_shape_reason(resolved, home)
|
||||
|
|
|
|||
|
|
@ -130,6 +130,32 @@ ACTING_SUBAGENT_TOOL_NAMES: frozenset[str] = frozenset({
|
|||
"list_available_tools",
|
||||
})
|
||||
|
||||
# Cyber Pro keeps the acting-child lineage and custody contract, while exposing
|
||||
# the existing review, skill and owner-runtime tools. Commit/live-body tools
|
||||
# stay outside this extension and explicit task disabled_tools still win.
|
||||
CYBER_PRO_ACTING_TOOL_NAMES: frozenset[str] = frozenset({
|
||||
"review_status", "preflight_review", "advisory_review",
|
||||
"task_acceptance_review", "plan_task",
|
||||
"list_skills", "skill_preflight", "skill_review", "skill_exec",
|
||||
"toggle_skill", "skill_owner_action",
|
||||
"set_tool_timeout", "request_deep_self_review", "toggle_evolution",
|
||||
"toggle_consciousness",
|
||||
})
|
||||
|
||||
|
||||
def acting_tool_names_for_context(ctx: object) -> frozenset[str]:
|
||||
"""Return the acting allowlist after applying the effective Cyber mode."""
|
||||
names = set(ACTING_SUBAGENT_TOOL_NAMES)
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(get_runtime_mode(), "cyber_pro"):
|
||||
names.update(CYBER_PRO_ACTING_TOOL_NAMES)
|
||||
except Exception:
|
||||
pass
|
||||
return frozenset(names)
|
||||
|
||||
READ_ONLY_PARALLEL_TOOLS: frozenset[str] = frozenset({
|
||||
"read_file", "list_files",
|
||||
"search_code", "query_code", "recent_tasks",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ _MISSING_EXECUTABLE_RE = re.compile(r"Executable doesn't exist at ([^\n]+)")
|
|||
_SUPPORTED_BROWSER_ENGINES = frozenset({"chromium", "webkit"})
|
||||
|
||||
|
||||
def _runtime_mode_for_browser(ctx: Any) -> str:
|
||||
"""Read the effective mode for owner-control browser operations."""
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
|
||||
return get_runtime_mode()
|
||||
except Exception:
|
||||
return "advanced"
|
||||
|
||||
|
||||
def _normalize_browser_engine(engine: str = "") -> str:
|
||||
value = str(engine or "chromium").strip().lower()
|
||||
if value not in _SUPPORTED_BROWSER_ENGINES:
|
||||
|
|
@ -445,7 +455,8 @@ def _ensure_browser(ctx: ToolContext, *, engine: str = "chromium", device: str =
|
|||
def route_request(route: Any) -> None:
|
||||
try:
|
||||
reason = browser_policy.browser_request_block_reason(
|
||||
route.request, ctx, restricted=readonly_subagent)
|
||||
route.request, ctx, restricted=readonly_subagent,
|
||||
runtime_mode=_runtime_mode_for_browser(ctx))
|
||||
except Exception:
|
||||
log.warning("Browser request policy could not read target identity", exc_info=True)
|
||||
reason = "BROWSER_POLICY_UNAVAILABLE: runtime service identity could not be read"
|
||||
|
|
@ -1000,7 +1011,10 @@ def _browser_action(ctx: ToolContext, action: str, selector: str = "",
|
|||
elif normalized_action == "evaluate":
|
||||
if not value:
|
||||
return "Error: value (JS code) required for evaluate"
|
||||
if reason := browser_policy.browser_evaluate_block_reason(str(getattr(page, "url", "") or ""), value, ctx):
|
||||
if reason := browser_policy.browser_evaluate_block_reason(
|
||||
str(getattr(page, "url", "") or ""), value, ctx,
|
||||
runtime_mode=_runtime_mode_for_browser(ctx),
|
||||
):
|
||||
return reason
|
||||
try:
|
||||
result = _evaluate_bounded(page, value, effective_default_ms)
|
||||
|
|
|
|||
|
|
@ -523,6 +523,11 @@ def _record_commit_attempt(
|
|||
scope_model = _req("scope_model")
|
||||
triad_raw_results = _req("triad_raw_results", None)
|
||||
scope_raw_result = _req("scope_raw_result", None)
|
||||
# Ordinary advisory continuation is not an author finish. Only an
|
||||
# explicit caller-supplied record is persisted here; the review
|
||||
# findings and advisory override remain the evidence for an unmarked
|
||||
# successful commit.
|
||||
author_disposition = _req("author_disposition", None)
|
||||
block_class = _req("block_class")
|
||||
rebuttal_sha256 = _req("rebuttal_sha256")
|
||||
paid = _req("paid", False)
|
||||
|
|
@ -671,6 +676,8 @@ def _record_commit_attempt(
|
|||
if scope_raw_result is not None
|
||||
else getattr(existing, "scope_raw_result", None) or {}
|
||||
),
|
||||
author_disposition=(dict(author_disposition)
|
||||
if isinstance(author_disposition, dict) else {}),
|
||||
block_class=block_class or str(getattr(existing, "block_class", "") or ""),
|
||||
rebuttal_sha256=rebuttal_sha256 or str(getattr(existing, "rebuttal_sha256", "") or ""),
|
||||
paid=bool(paid or getattr(existing, "paid", False)),
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
}, _update_identity),
|
||||
ToolEntry("toggle_evolution", {
|
||||
"name": "toggle_evolution",
|
||||
"description": "Enable or disable evolution mode. When enabled, Ouroboros runs continuous self-improvement cycles. Enabling requires runtime_mode 'advanced' or 'pro'; it is refused in 'light' mode.",
|
||||
"description": "Enable or disable evolution mode. When enabled, Ouroboros runs continuous self-improvement cycles. Enabling requires runtime_mode 'advanced', 'pro', or 'cyber_pro'; it is refused in 'light' mode.",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"enabled": {"type": "boolean", "description": "true to enable, false to disable"},
|
||||
"objective": {"type": "string", "default": "", "description": "Optional Evolution Campaign objective when enabling."},
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from ouroboros.contracts.skill_payload_policy import (
|
|||
from ouroboros.tools.core_file_tools import ( # noqa: F401
|
||||
_ListingFailure,
|
||||
_MEMORY_AT_DRIVE_MEMORY,
|
||||
_raw_owner_secret_access_allowed,
|
||||
_SKILL_OWNER_STATE_FILENAMES,
|
||||
_SUBAGENT_SECRET_FILE_NAMES,
|
||||
_access_or_block,
|
||||
|
|
@ -966,6 +967,8 @@ def _code_search(ctx: ToolContext, query: str, path: str = ".",
|
|||
# fallback. Names/paths stay; values become ***.
|
||||
if normalized != "user_files" and not subagent_readonly:
|
||||
return result_text
|
||||
if normalized == "user_files" and _raw_owner_secret_access_allowed(ctx):
|
||||
return result_text
|
||||
masked_text, masked = mask_secret_bytes(
|
||||
result_text, mask_opaque=normalized not in {"active_workspace", "system_repo"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,19 @@ log = logging.getLogger(__name__)
|
|||
_SKILL_OWNER_STATE_FILENAMES = SKILL_OWNER_STATE_FILENAMES
|
||||
|
||||
|
||||
def _raw_owner_secret_access_allowed(ctx: ToolContext) -> bool:
|
||||
"""Cyber Pro owner mode may inspect explicitly selected home-file bytes."""
|
||||
if is_restricted_subagent_profile(ctx):
|
||||
return False
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
return runtime_mode_at_least(get_runtime_mode(), "cyber_pro")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _direct_resource_binding(
|
||||
ctx: ToolContext,
|
||||
supplied: Any,
|
||||
|
|
@ -501,9 +514,9 @@ def _profile_roots_hint(ctx: ToolContext, operation: str) -> str:
|
|||
model turns a dead-end error into a self-correcting retry instead of a
|
||||
probe loop over blocked roots (v6.70.0)."""
|
||||
try:
|
||||
from ouroboros.tool_access import _POLICY
|
||||
from ouroboros.tool_access import _POLICY, _effective_policy_profile
|
||||
|
||||
policy = _POLICY.get(active_tool_profile(ctx), {})
|
||||
policy = _POLICY.get(_effective_policy_profile(active_tool_profile(ctx)), {})
|
||||
visible = sorted(root for root, ops in policy.items() if operation in ops)
|
||||
return f" Roots your profile can {operation}: {', '.join(visible) or '(none)'}."
|
||||
except Exception:
|
||||
|
|
@ -699,10 +712,13 @@ def _read_file(
|
|||
))
|
||||
try:
|
||||
content = read_text(target)
|
||||
raw_owner_secret_access = _raw_owner_secret_access_allowed(ctx)
|
||||
rendered = _render_line_slice(_root_display_path(normalized, path), content,
|
||||
max_lines=max_lines, start_line=start_line, start_char=start_char,
|
||||
extent=extent, mask_secrets=is_restricted_subagent_profile(ctx))
|
||||
if normalized == "user_files":
|
||||
extent=extent, mask_secrets=(
|
||||
is_restricted_subagent_profile(ctx) and not raw_owner_secret_access
|
||||
))
|
||||
if normalized == "user_files" and not raw_owner_secret_access:
|
||||
# Egress seam for owner-home reads (#447 X1/В23): the file may be
|
||||
# read, but raw credential bytes never enter model context/history —
|
||||
# the masked form (***) may. Masking happens on the rendered slice;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,17 @@ def is_restricted_subagent_profile(ctx: ToolContext) -> bool:
|
|||
# state. Acting children may WRITE their isolated surface but never read owner
|
||||
# secrets; the resource WRITE distinction lives in _local_readonly_resource_block.
|
||||
from ouroboros.tool_access import active_tool_profile
|
||||
return active_tool_profile(ctx) in ("local_readonly_subagent", "acting_subagent")
|
||||
profile = active_tool_profile(ctx)
|
||||
if profile == "acting_subagent":
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
if runtime_mode_at_least(get_runtime_mode(), "cyber_pro"):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return profile in ("local_readonly_subagent", "acting_subagent")
|
||||
|
||||
|
||||
def _is_subagent_secret_data_path(norm: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def _protected_paths_block_message(paths, *, runtime_mode: str, action: str) ->
|
|||
return (
|
||||
f"⚠️ CORE_PROTECTION_BLOCKED: runtime_mode={runtime_mode!r} refuses "
|
||||
f"to {action} protected Ouroboros core/contract/release path(s): {rendered}. "
|
||||
"Use runtime_mode='pro' and pass the normal triad + scope review before "
|
||||
"Use runtime_mode='pro' or 'cyber_pro' and pass the normal triad + scope review before "
|
||||
"committing protected surfaces."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,17 @@ def _next_step(wave: dict, *, enforcement: str, cap: Optional[int], cycles_paid:
|
|||
aggregate = str(wave.get("aggregate") or "")
|
||||
fp = str(wave.get("request_fingerprint") or "")
|
||||
at_cap = cap is not None and cycles_paid >= cap
|
||||
author = wave.get("author_disposition")
|
||||
author_note = ""
|
||||
if isinstance(author, dict) and author.get("disposition") and author.get("rationale"):
|
||||
author_note = (
|
||||
f"Author finish recorded as {author.get('disposition')} against this exact "
|
||||
"review fingerprint; raw reviewer findings remain evidence. "
|
||||
)
|
||||
if enforcement == "blocking":
|
||||
author_note += "Blocking enforcement still holds the open plan gate. "
|
||||
else:
|
||||
author_note += "Advisory enforcement permits proceeding with the review open. "
|
||||
if bool(wave.get("closed")):
|
||||
if plan_review_notes_are_annotatable(wave):
|
||||
return (
|
||||
|
|
@ -137,7 +148,7 @@ def _next_step(wave: dict, *, enforcement: str, cap: Optional[int], cycles_paid:
|
|||
# call). Quorum arithmetic, per-slot typed states above, and the replay mechanics;
|
||||
# the decision (revise the spec, wait, escalate, proceed if permitted) is the LLM's.
|
||||
counts = wave.get("counts") if isinstance(wave.get("counts"), dict) else {}
|
||||
text = (
|
||||
text = author_note + (
|
||||
f"DEGRADED: parseable reviewer verdicts {counts.get('parseable', 0)} of "
|
||||
f"{counts.get('configured', 0)} configured slot(s) — below the review quorum "
|
||||
f"({counts.get('quorum', '?')}). Per-slot typed states (code and reset time, when "
|
||||
|
|
@ -160,7 +171,7 @@ def _next_step(wave: dict, *, enforcement: str, cap: Optional[int], cycles_paid:
|
|||
)
|
||||
elif aggregate == "REVIEW_REQUIRED":
|
||||
blocking = [f for f in wave.get("findings") or [] if f.get("class") == "blocking"]
|
||||
text = (
|
||||
text = author_note + (
|
||||
"Notes are optional. Disposition need_evidence (accept | reject | defer, with a rationale) in ONE "
|
||||
f"call: plan_task(review_disposition={{review_fingerprint: '{fp}', items: [...]}}) — no "
|
||||
"reviewer call, no cycle. "
|
||||
|
|
@ -173,7 +184,7 @@ def _next_step(wave: dict, *, enforcement: str, cap: Optional[int], cycles_paid:
|
|||
"(new fingerprint, next paid cycle) or a reject that the next paid delta cycle judges. "
|
||||
)
|
||||
else:
|
||||
text = (
|
||||
text = author_note + (
|
||||
"Blocking findings: accept ⇒ change the spec and re-call plan_task (new fingerprint, "
|
||||
f"{'the cap is reached — no further paid cycle' if at_cap else 'next paid cycle ' + str(cycles_paid + 1) + ('' if cap is None else f' of {cap}')}); "
|
||||
"reject ⇒ record reject + rationale via review_disposition naming this fingerprint — it "
|
||||
|
|
@ -280,6 +291,9 @@ def _render_wave(
|
|||
if wave.get("dispositions"):
|
||||
lines += ["", "### Dispositions", "", "```json",
|
||||
json.dumps(wave.get("dispositions"), ensure_ascii=False, indent=2), "```"]
|
||||
if isinstance(wave.get("author_disposition"), dict):
|
||||
lines += ["", "### Author finish", "", "```json",
|
||||
json.dumps(wave.get("author_disposition"), ensure_ascii=False, indent=2), "```"]
|
||||
if wave.get("closure_notes") or notes:
|
||||
lines += ["", "Closure notes: " + "; ".join([*(wave.get("closure_notes") or []), *(notes or [])])]
|
||||
outcome, closed = wave_control_state(wave)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ from ouroboros.tools.plan_review_references import (
|
|||
)
|
||||
from ouroboros.tools.registry import ToolContext, ToolEntry
|
||||
from ouroboros.tools.review_helpers import review_wave_binding_fence, review_wave_budget_gate
|
||||
from ouroboros.review_records import build_author_disposition_from_mapping
|
||||
from ouroboros.tools.review_synthesis import (
|
||||
PLAN_REVIEW_CONTROL_PREFIX,
|
||||
)
|
||||
|
|
@ -104,7 +105,6 @@ log = logging.getLogger(__name__)
|
|||
def _plan_review_wrapper_timeout_sec() -> float:
|
||||
return float(get_llm_transport_read_timeout_sec() + get_finalization_grace_sec())
|
||||
|
||||
|
||||
def _plan_task_tool_timeout_sec() -> float:
|
||||
# ``agent_session`` reviewers inherit the task's existing absolute
|
||||
# lifetime, which is deliberately much longer than an API transport read.
|
||||
|
|
@ -116,14 +116,12 @@ def _plan_task_tool_timeout_sec() -> float:
|
|||
) + get_finalization_grace_sec()
|
||||
_TASK_EVIDENCE_RESULT_CHARS = 6_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlanRequest:
|
||||
goal: str
|
||||
plan: str
|
||||
spec: Any
|
||||
|
||||
|
||||
_SPEC_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
|
|
@ -210,6 +208,7 @@ _DISPOSITION_SCHEMA = {
|
|||
),
|
||||
"properties": {
|
||||
"review_fingerprint": {"type": "string"},
|
||||
"author_disposition": plan_spec.AUTHOR_DISPOSITION_SCHEMA,
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
@ -226,7 +225,6 @@ _DISPOSITION_SCHEMA = {
|
|||
"required": ["review_fingerprint", "items"],
|
||||
}
|
||||
|
||||
|
||||
def get_tools():
|
||||
return [
|
||||
ToolEntry(
|
||||
|
|
@ -266,13 +264,10 @@ def get_tools():
|
|||
)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- handler
|
||||
|
||||
|
||||
_SPEC_FIELDS = frozenset(_SPEC_SCHEMA["properties"])
|
||||
|
||||
|
||||
def _vacuous(name: str, value: object) -> bool:
|
||||
"""Nothing was said in this optional envelope field: absent, blank prose, or the
|
||||
spec's DECLARED keys each holding their schema-default empty value. A non-empty
|
||||
|
|
@ -284,7 +279,6 @@ def _vacuous(name: str, value: object) -> bool:
|
|||
and all(member in (None, "", []) for member in value.values()))
|
||||
return isinstance(value, str) and not value.strip()
|
||||
|
||||
|
||||
def _vacuous_disposition(value: object) -> bool:
|
||||
"""A schema-shaped but empty disposition (models fill optional objects with defaults).
|
||||
An UNKNOWN key or a non-empty items list is never vacuous: refused, not ignored."""
|
||||
|
|
@ -292,13 +286,11 @@ def _vacuous_disposition(value: object) -> bool:
|
|||
return False
|
||||
return not str(value.get("review_fingerprint") or "").strip() and not value.get("items")
|
||||
|
||||
|
||||
def _typed_refusal(ctx: ToolContext, code: str, text: str) -> str:
|
||||
"""Publish a refusal the producer ALREADY knows about (D02). The text ABI is
|
||||
unchanged; only the registry-visible status stops reading as a successful call."""
|
||||
return _publish_tool_result(ctx, ToolResult(status=TOOL_CODE_SPECS[code].status, code=code, text=text))
|
||||
|
||||
|
||||
def _handle_plan_task(ctx: ToolContext, **params) -> str:
|
||||
raw_disposition = params.get("review_disposition")
|
||||
# The registry refuses unknown params; a vacuous envelope field carries no plan.
|
||||
|
|
@ -350,12 +342,10 @@ def _handle_plan_task(ctx: ToolContext, **params) -> str:
|
|||
log.error("plan_task failed: %s", e, exc_info=True)
|
||||
return _plan_unavailable(ctx, f"ERROR: Plan review failed: {e}", "review_failed")
|
||||
|
||||
|
||||
# A FAULT of this call (broken review, unreadable authority); every other reason — budget,
|
||||
# configuration, context — is an availability outcome typed `unavailable`, never a fake success.
|
||||
_PLAN_FAULT_REASONS = frozenset({"review_failed", "plan_review_exact_artifact_unavailable", "plan_review_custody_invalid"})
|
||||
|
||||
|
||||
def _plan_unavailable(ctx: ToolContext, message: str, reason: str) -> str:
|
||||
"""Persist a retryable availability outcome (the current fingerprint stays open-unavailable)."""
|
||||
code = "TOOL_ERROR" if reason in _PLAN_FAULT_REASONS else "CAPABILITY_UNAVAILABLE"
|
||||
|
|
@ -368,7 +358,6 @@ def _plan_unavailable(ctx: ToolContext, message: str, reason: str) -> str:
|
|||
ctx, "TOOL_ERROR", f"{message}\nERROR: PLAN_REVIEW_STATE_PERSIST_FAILED: {exc}")
|
||||
return _typed_refusal(ctx, code, message)
|
||||
|
||||
|
||||
def _planning_state_location(ctx: ToolContext) -> tuple[pathlib.Path, str]:
|
||||
root = pathlib.Path(str(getattr(ctx, "budget_drive_root", "") or ctx.drive_root))
|
||||
task_id = str(getattr(ctx, "task_id", "") or "").strip()
|
||||
|
|
@ -376,10 +365,8 @@ def _planning_state_location(ctx: ToolContext) -> tuple[pathlib.Path, str]:
|
|||
raise ValueError("PLAN_REVIEW_TASK_ID_REQUIRED: durable review state must belong to a real task")
|
||||
return root, task_id
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- inputs / packet
|
||||
|
||||
|
||||
def _evidence_deny_paths(ctx: ToolContext) -> list[str]:
|
||||
"""Paths evidence may never attach, whatever root the caller declares (C-06): the runtime
|
||||
data plane and the live settings file are a boundary, not a heuristic — an operator subject
|
||||
|
|
@ -401,7 +388,6 @@ def _evidence_deny_paths(ctx: ToolContext) -> list[str]:
|
|||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _plan_fingerprint(goal: str, plan: str, spec: dict, manifest_hash: str, constitutional: bool) -> str:
|
||||
"""Identity of one review request (F4): goal, prose, canonical spec, evidence identity,
|
||||
the constitutional fact — never the exploration log (it changes no obligation)."""
|
||||
|
|
@ -409,7 +395,6 @@ def _plan_fingerprint(goal: str, plan: str, spec: dict, manifest_hash: str, cons
|
|||
"constitutional": bool(constitutional)}
|
||||
return sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _task_evidence_reader(root: pathlib.Path) -> Callable[[str], Optional[str]]:
|
||||
"""Task-result projection; the evidence resolver hashes, budgets and redacts it."""
|
||||
def _read(task_id: str) -> Optional[str]:
|
||||
|
|
@ -431,12 +416,10 @@ def _task_evidence_reader(root: pathlib.Path) -> Callable[[str], Optional[str]]:
|
|||
return json.dumps(projection, ensure_ascii=False, indent=2, default=str)
|
||||
return _read
|
||||
|
||||
|
||||
# W3 host attachment is bounded like the agent's own evidence list (MAX_LIST_ITEMS honoured
|
||||
# locators per task); what the cap drops is a NAMED `reviewer_request_cap` omission, never silent.
|
||||
_REVIEWER_REQUEST_CAP = plan_spec.MAX_LIST_ITEMS
|
||||
|
||||
|
||||
def _reviewer_requested_locators(ctx: ToolContext, state_root: pathlib.Path) -> tuple[list[str], list[str]]:
|
||||
"""``(honoured, dropped)`` `need_evidence` locators from this task's earlier cycles
|
||||
(`need_evidence_seen`, kept sorted): the cap keeps the lexicographically first ones,
|
||||
|
|
@ -461,7 +444,6 @@ def _reviewer_requested_locators(ctx: ToolContext, state_root: pathlib.Path) ->
|
|||
seen.append(loc)
|
||||
return seen, dropped
|
||||
|
||||
|
||||
def _prepare_plan_inputs(ctx: ToolContext, request: "_PlanRequest", state_root: pathlib.Path) -> dict:
|
||||
"""The ONE preamble the paid path and the dry-run seam share: normalize the spec (with the
|
||||
envelope's goal injected), resolve the subject roots, derive `constitutional`, attach the
|
||||
|
|
@ -525,10 +507,8 @@ def _prepare_plan_inputs(ctx: ToolContext, request: "_PlanRequest", state_root:
|
|||
"fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- review
|
||||
|
||||
|
||||
async def _run_plan_review_async(ctx: ToolContext, request: _PlanRequest) -> str:
|
||||
try:
|
||||
state_root, task_id = _planning_state_location(ctx)
|
||||
|
|
@ -781,14 +761,12 @@ async def _run_plan_review_async(ctx: ToolContext, request: _PlanRequest) -> str
|
|||
aggregate, agg["counts"], cycles_paid=paid_now, cap=cap))
|
||||
return _publish_rendered_wave(ctx, stored, cap=cap, cycles_paid=paid_now, enforcement=enforcement, reminder=reminder)
|
||||
|
||||
|
||||
def _last_paid_wave(state: dict) -> Optional[dict]:
|
||||
for wave in reversed(state.get("waves") or []):
|
||||
if wave.get("paid") and not wave.get("compact"):
|
||||
return wave
|
||||
return None
|
||||
|
||||
|
||||
def build_plan_review_packet_for_dry_run(ctx: ToolContext, request: "_PlanRequest") -> dict:
|
||||
"""Assemble the packet SHAPE of a fresh cycle (cycle_index=1, no prior-cycle section) with the
|
||||
task's recorded reviewer requests attached (W3), WITHOUT dispatching or recording anything.
|
||||
|
|
@ -813,7 +791,6 @@ def build_plan_review_packet_for_dry_run(ctx: ToolContext, request: "_PlanReques
|
|||
"user_content": user_content, "fingerprint": prepared["fingerprint"],
|
||||
}
|
||||
|
||||
|
||||
def _cycles_exhausted(
|
||||
ctx: ToolContext, state: dict, state_root: pathlib.Path, task_id: str, *,
|
||||
cap: int, cycles_paid: int, enforcement: str, reminder: str,
|
||||
|
|
@ -891,15 +868,13 @@ def _cycles_exhausted(
|
|||
return _publish_plan_review_projection(
|
||||
ctx, {"aggregate_signal": "REVISE_PLAN", "closed": False}, text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- disposition
|
||||
|
||||
|
||||
def _apply_disposition(ctx: ToolContext, disposition: dict) -> str:
|
||||
def _bad(text: str) -> str: # every refusal below is an argument-shape refusal
|
||||
return _typed_refusal(ctx, "TOOL_ARG_ERROR", text)
|
||||
|
||||
unknown = sorted(str(k) for k in disposition if k not in {"review_fingerprint", "items"})
|
||||
unknown = sorted(str(k) for k in disposition if k not in {"review_fingerprint", "items", "author_disposition"})
|
||||
if unknown:
|
||||
return _bad("ERROR: PLAN_REVIEW_DISPOSITION_INVALID: unknown fields: " + ", ".join(unknown))
|
||||
fingerprint = str(disposition.get("review_fingerprint") or "").strip()
|
||||
|
|
@ -950,6 +925,17 @@ def _apply_disposition(ctx: ToolContext, disposition: dict) -> str:
|
|||
"decision": str(item.get("decision") or "").strip().lower()[:40], # enum-like, bounded
|
||||
"rationale": plan_spec.bounded_text(item.get("rationale"), plan_spec.MAX_FINDING_TEXT_CHARS),
|
||||
})
|
||||
author_record = None
|
||||
if disposition.get("author_disposition") is not None:
|
||||
if enforcement != "advisory":
|
||||
return _bad(
|
||||
"ERROR: PLAN_REVIEW_DISPOSITION_INVALID: author_disposition is advisory-only; "
|
||||
"the selected blocking enforcement remains authoritative"
|
||||
)
|
||||
try:
|
||||
author_record = build_author_disposition_from_mapping(disposition["author_disposition"], subject_hash=fingerprint, reviewer_signal=str(wave.get("aggregate") or ""), enforcement=enforcement)
|
||||
except ValueError as exc:
|
||||
return _bad("ERROR: PLAN_REVIEW_DISPOSITION_INVALID: " + str(exc))
|
||||
known = {str(f.get("finding_id") or "") for f in wave.get("findings") or []}
|
||||
unknown_ids = sorted({i["finding_id"] for i in items if i["finding_id"] not in known})
|
||||
if unknown_ids:
|
||||
|
|
@ -973,11 +959,14 @@ def _apply_disposition(ctx: ToolContext, disposition: dict) -> str:
|
|||
"disposition_recorded_at": disposition_recorded_at,
|
||||
"supersedes_wave_artifact": prior_ref,
|
||||
})
|
||||
if author_record is not None:
|
||||
exact["author_disposition"] = author_record
|
||||
disposition_ref = _persist_plan_review_wave_artifact(root, task_id, exact)
|
||||
stored = record_plan_review_dispositions(
|
||||
root, task_id, fingerprint=fingerprint, dispositions=items,
|
||||
closed=bool(closure["closed"]), closure_notes=closure_notes,
|
||||
wave_artifact=disposition_ref, recorded_at=disposition_recorded_at,
|
||||
author_disposition=author_record,
|
||||
)
|
||||
except (OSError, TimeoutError, ValueError) as exc:
|
||||
return _typed_refusal(
|
||||
|
|
@ -990,5 +979,4 @@ def _apply_disposition(ctx: ToolContext, disposition: dict) -> str:
|
|||
return _publish_rendered_wave(ctx, stored, cap=cap, cycles_paid=cycles_paid,
|
||||
enforcement=enforcement, notes=list(closure["notes"]))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ rendering
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ def plan_review_authority_core(
|
|||
"identity": {key: copy.deepcopy(latest[key]) for key in ("cycle_index", "request_fingerprint", "previous_fingerprint", "spec_hash", "evidence_manifest_hash", "aggregate", "closed", "paid") if key in latest},
|
||||
"goal": spec.get("goal"), "acceptance_claims": recent(spec.get("acceptance_claims")),
|
||||
"findings": recent(latest.get("findings")), "dispositions": recent(latest.get("dispositions")),
|
||||
"author_disposition": copy.deepcopy(latest.get("author_disposition"))
|
||||
if isinstance(latest.get("author_disposition"), dict) else None,
|
||||
}
|
||||
core["waves"] = [_compact_plan_review_wave(wave) if isinstance(wave, dict) and not wave.get("compact") else wave for wave in core["waves"]]
|
||||
core["need_evidence_seen"] = recent(core.get("need_evidence_seen"))
|
||||
|
|
|
|||
|
|
@ -45,6 +45,16 @@ PACKET_PRIOR_CYCLES_CHARS = 60_000
|
|||
|
||||
FINDING_CLASSES = ("blocking", "note", "need_evidence")
|
||||
AGGREGATES = ("GREEN", "REVIEW_REQUIRED", "REVISE_PLAN", "DEGRADED")
|
||||
|
||||
AUTHOR_DISPOSITION_SCHEMA = {
|
||||
"type": "object", "additionalProperties": False,
|
||||
"description": "Optional advisory author finish bound to the exact review fingerprint; it never manufactures reviewer PASS or releases a blocking gate.",
|
||||
"properties": {
|
||||
"disposition": {"type": "string", "enum": ["accepted", "rejected", "partial", "deferred"]},
|
||||
"rationale": {"type": "string"},
|
||||
},
|
||||
"required": ["disposition", "rationale"],
|
||||
}
|
||||
DISPOSITION_DECISIONS = ("accept", "reject", "defer")
|
||||
|
||||
_SPEC_STRING_LISTS = ("in_scope", "non_goals", "invariants", "affected_resources", "evidence")
|
||||
|
|
|
|||
|
|
@ -24,13 +24,15 @@ import ouroboros.tools.registry_guards as registry_guards
|
|||
import ouroboros.tools.shell_guards as shell_guards
|
||||
import ouroboros.tools.tool_resolution as tool_resolution
|
||||
from ouroboros.runtime_mode_policy import (
|
||||
PROTECTED_RUNTIME_PATHS,
|
||||
core_patch_notice,
|
||||
mode_allows_protected_write,
|
||||
protected_paths_in,
|
||||
protected_write_block_message,
|
||||
)
|
||||
from ouroboros.tool_capabilities import (
|
||||
ACTING_SUBAGENT_MODE,
|
||||
ACTING_SUBAGENT_TOOL_NAMES,
|
||||
acting_tool_names_for_context,
|
||||
CORE_TOOL_NAMES,
|
||||
LOCAL_READONLY_SUBAGENT_MODE,
|
||||
LOCAL_READONLY_SUBAGENT_TOOL_NAMES,
|
||||
|
|
@ -73,6 +75,7 @@ from ouroboros.tools.tool_result import (
|
|||
_install_tool_result_sidecar,
|
||||
_published_tool_result,
|
||||
_restore_tool_result_sidecar,
|
||||
_replace_tool_result,
|
||||
)
|
||||
from ouroboros.tools.registry_guards import (
|
||||
_EPHEMERAL_ALLOWED_TOOLS,
|
||||
|
|
@ -267,6 +270,28 @@ def _protected_write_block_result(*, path: str, runtime_mode: str, action: str)
|
|||
)
|
||||
|
||||
|
||||
def _append_shell_core_notice(
|
||||
result: str | ToolResult, raw_cmd: Any, *, paths: list[str] | None = None,
|
||||
) -> str | ToolResult:
|
||||
"""Attach the same protected-change notice used by editor writes.
|
||||
|
||||
The shell guard deliberately returns ``None`` for Pro/Cyber rewrites, so
|
||||
the post-execution path records that a protected surface was attempted
|
||||
without turning the mode-aware allowance into an unreviewed success claim.
|
||||
"""
|
||||
text = (" ".join(str(part) for part in raw_cmd)
|
||||
if isinstance(raw_cmd, list) else str(raw_cmd or "")).replace("\\", "/").lower()
|
||||
paths = list(paths or [path for path in sorted(PROTECTED_RUNTIME_PATHS) if path.lower() in text])
|
||||
if not paths:
|
||||
return result
|
||||
notice = core_patch_notice(paths)
|
||||
if isinstance(result, ToolResult):
|
||||
if result.status == "blocked":
|
||||
return result
|
||||
return _replace_tool_result(result, text=result.text + "\n\n" + notice)
|
||||
return str(result) + "\n\n" + notice
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Tool registry; modules export ``get_tools()``."""
|
||||
|
||||
|
|
@ -484,7 +509,7 @@ class ToolRegistry:
|
|||
names.add("verify_and_record")
|
||||
return frozenset(names)
|
||||
if self._is_acting_subagent():
|
||||
return ACTING_SUBAGENT_TOOL_NAMES
|
||||
return acting_tool_names_for_context(self._ctx)
|
||||
return frozenset(set(self.available_tools()) | set(META_TOOL_NAMES))
|
||||
|
||||
def available_tools(self) -> List[str]:
|
||||
|
|
@ -499,7 +524,7 @@ class ToolRegistry:
|
|||
if _presence_tool_allowed(self._ctx, e.name)
|
||||
if _builtin_tool_availability(e.name, self._ctx)[0]
|
||||
if not local_readonly_subagent or self._readonly_tool_allowed(e.name)
|
||||
if not acting_subagent or e.name in ACTING_SUBAGENT_TOOL_NAMES
|
||||
if not acting_subagent or e.name in acting_tool_names_for_context(self._ctx)
|
||||
]
|
||||
|
||||
def _schema_for_entry(self, entry: ToolEntry) -> Dict[str, Any]:
|
||||
|
|
@ -673,7 +698,7 @@ class ToolRegistry:
|
|||
if _presence_tool_allowed(self._ctx, entry.name)
|
||||
if entry.name not in unavailable_tools
|
||||
if not local_readonly_subagent or self._readonly_tool_allowed(entry.name)
|
||||
if not acting_subagent or entry.name in ACTING_SUBAGENT_TOOL_NAMES
|
||||
if not acting_subagent or entry.name in acting_tool_names_for_context(self._ctx)
|
||||
if not ephemeral_turn or entry.name in _EPHEMERAL_ALLOWED_TOOLS # CW3: default-deny allowlist
|
||||
for schema in self._schemas_for_entry(entry)
|
||||
]
|
||||
|
|
@ -796,13 +821,13 @@ class ToolRegistry:
|
|||
continue
|
||||
if local_readonly_subagent and not self._readonly_tool_allowed(e.name):
|
||||
continue
|
||||
if acting_subagent and e.name not in ACTING_SUBAGENT_TOOL_NAMES:
|
||||
if acting_subagent and e.name not in acting_tool_names_for_context(self._ctx):
|
||||
continue
|
||||
if ephemeral_turn and e.name not in _EPHEMERAL_ALLOWED_TOOLS:
|
||||
continue # CW3: the core/initial envelope is allowlisted too, not just schemas(core_only=False)
|
||||
if (
|
||||
(local_readonly_subagent and self._readonly_tool_allowed(e.name))
|
||||
or (acting_subagent and e.name in ACTING_SUBAGENT_TOOL_NAMES)
|
||||
or (acting_subagent and e.name in acting_tool_names_for_context(self._ctx))
|
||||
or e.name in CORE_TOOL_NAMES
|
||||
or e.name in ("list_available_tools", "enable_tools")
|
||||
):
|
||||
|
|
@ -851,7 +876,7 @@ class ToolRegistry:
|
|||
acting_subagent = self._is_acting_subagent()
|
||||
if self._is_local_readonly_subagent() and not self._readonly_tool_allowed(requested):
|
||||
return "hidden by the read-only subagent profile"
|
||||
if acting_subagent and requested not in ACTING_SUBAGENT_TOOL_NAMES:
|
||||
if acting_subagent and requested not in acting_tool_names_for_context(self._ctx):
|
||||
return "hidden by the acting subagent profile"
|
||||
return None
|
||||
|
||||
|
|
@ -887,7 +912,7 @@ class ToolRegistry:
|
|||
return None # CW3: allowlist-consistent with schemas()/execute() (so enable_tools can't surface a denied tool)
|
||||
if local_readonly_subagent and not self._readonly_tool_allowed(requested):
|
||||
return None
|
||||
if acting_subagent and requested not in ACTING_SUBAGENT_TOOL_NAMES:
|
||||
if acting_subagent and requested not in acting_tool_names_for_context(self._ctx):
|
||||
return None
|
||||
return self._schema_for_entry(entry)
|
||||
try:
|
||||
|
|
@ -1329,6 +1354,18 @@ class ToolRegistry:
|
|||
elif early_error is not None:
|
||||
return early_error
|
||||
|
||||
if (
|
||||
name in _PROCESS_COMMAND_TOOLS
|
||||
and mode_allows_protected_write(_runtime_mode)
|
||||
and targets_system_repo
|
||||
and getattr(self._ctx, "_protected_shell_notice_paths", None)
|
||||
):
|
||||
result = _append_shell_core_notice(
|
||||
result,
|
||||
args.get("cmd", args.get("command", "")),
|
||||
paths=getattr(self._ctx, "_protected_shell_notice_paths", None),
|
||||
)
|
||||
|
||||
return _compose_execute_result_result(name, result, _route_note, safety_msg) if _route_note or safety_msg else result
|
||||
|
||||
def execute_result(self, name: str, args: Dict[str, Any]) -> ToolResult:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import pathlib
|
|||
import subprocess
|
||||
|
||||
from ouroboros.contracts.skill_payload_policy import SKILL_OWNER_STATE_STEMS
|
||||
from ouroboros.runtime_mode_policy import (
|
||||
PROTECTED_RUNTIME_PATHS,
|
||||
mode_allows_protected_write,
|
||||
runtime_mode_at_least,
|
||||
)
|
||||
|
||||
import ouroboros.tools.registry_guards as registry_guards
|
||||
from ouroboros.tools.tool_result import (
|
||||
|
|
@ -506,8 +511,9 @@ def _run_shell_safety_check(
|
|||
# must use write_file/edit_text, which apply the pro+grant gate).
|
||||
acting_self_worktree = self._acting_self_worktree()
|
||||
acting_subagent = self._is_acting_subagent()
|
||||
cyber_authority = runtime_mode_at_least(runtime_mode, "cyber_pro")
|
||||
argv = _registry().strip_leading_env_assignments(_registry().unwrap_env_argv(_registry().shell_argv(raw_cmd)))
|
||||
if _registry().sudo_noninteractive_violation(raw_cmd):
|
||||
if not cyber_authority and _registry().sudo_noninteractive_violation(raw_cmd):
|
||||
return ToolResult(
|
||||
status="blocked",
|
||||
code="SUDO_INTERACTIVE_BLOCKED",
|
||||
|
|
@ -518,7 +524,7 @@ def _run_shell_safety_check(
|
|||
while "//" in cmd_path_lower: cmd_path_lower = cmd_path_lower.replace("//", "/")
|
||||
# Subagents must not read owner secrets/credentials/control state via shell
|
||||
# (read_file already denies these). read_file is the gated inspection path.
|
||||
if (acting_subagent or self._is_local_readonly_subagent()) and _subagent_shell_targets_secret(
|
||||
if (acting_subagent or self._is_local_readonly_subagent()) and not cyber_authority and _subagent_shell_targets_secret(
|
||||
raw_cmd, ctx=self._ctx, cwd=getattr(binding, "target_path", None)):
|
||||
return ToolResult(
|
||||
status="blocked",
|
||||
|
|
@ -621,17 +627,17 @@ def _run_shell_safety_check(
|
|||
# detector takes the shared read-carve (pure read-only inspection of the
|
||||
# key/endpoint names is allowed; the write shape or any non-inspection
|
||||
# head still blocks) — the scope-floor precedent applied family-wide.
|
||||
if _detect_runtime_mode_elevation(cmd_lower, writeish=writeish):
|
||||
if not cyber_authority and _detect_runtime_mode_elevation(cmd_lower, writeish=writeish):
|
||||
return ToolResult(status="blocked", code="ELEVATION_BLOCKED", text="⚠️ ELEVATION_BLOCKED: shell command pattern looks like an OUROBOROS_RUNTIME_MODE elevation attempt (mentions ``save_settings`` together with ``OUROBOROS_RUNTIME_MODE``, or invokes ``ouroboros.config.save_settings`` directly). Runtime mode is owner-controlled — change it by stopping the agent and editing settings.json directly, then restart.")
|
||||
if _detect_context_mode_self_lowering(cmd_lower, writeish=writeish):
|
||||
if not cyber_authority and _detect_context_mode_self_lowering(cmd_lower, writeish=writeish):
|
||||
return ToolResult(status="blocked", code="CONTEXT_MODE_SELF_LOWERING_BLOCKED", text="⚠️ CONTEXT_MODE_SELF_LOWERING_BLOCKED: shell command pattern looks like an attempt to lower OUROBOROS_CONTEXT_MODE to low through settings.json or /api/owner/context-mode. Context mode is owner-controlled — ask the owner to change the Low/Max toggle or edit settings while the agent is stopped.")
|
||||
if _detect_safety_mode_self_lowering(cmd_lower, writeish=writeish):
|
||||
if not cyber_authority and _detect_safety_mode_self_lowering(cmd_lower, writeish=writeish):
|
||||
return ToolResult(status="blocked", code="SAFETY_MODE_SELF_LOWERING_BLOCKED", text="⚠️ SAFETY_MODE_SELF_LOWERING_BLOCKED: shell command pattern looks like an attempt to change OUROBOROS_SAFETY_MODE (e.g. to ``light``/``off``) through settings.json, /api/settings, or /api/owner/safety-mode. LLM-safety coverage is owner-controlled (BIBLE P3) — the agent must not reduce its own supervision. Ask the owner to change it via the dedicated /api/owner/safety-mode endpoint, or stop the agent and edit settings.json directly.")
|
||||
if _detect_owner_skill_attest_self_call(cmd_lower, writeish=writeish):
|
||||
return ToolResult(status="blocked", code="OWNER_SKILL_ATTESTATION_SELF_CALL_BLOCKED", text="⚠️ OWNER_SKILL_ATTESTATION_SELF_CALL_BLOCKED: shell command pattern looks like an attempt to loopback-POST /api/owner/skills/<skill>/attest-review. Owner-attestation skips the expensive LLM skill review and is OWNER-ONLY — the agent must not self-attest its own skill to bypass the immune system's review. Ask the owner to attest it from the Skills UI.")
|
||||
if _detect_mutative_toggle_self_change(cmd_lower, writeish=writeish):
|
||||
if not cyber_authority and _detect_mutative_toggle_self_change(cmd_lower, writeish=writeish):
|
||||
return ToolResult(status="blocked", code="ELEVATION_BLOCKED", text="⚠️ ELEVATION_BLOCKED: OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS is owner-controlled (it grants subagents write power against the live body). Change it by stopping the agent and editing settings.json directly, then restart — the agent must not self-enable mutative subagents.")
|
||||
if _detect_evolution_owner_control_self_change(cmd_lower, writeish=writeish):
|
||||
if not cyber_authority and _detect_evolution_owner_control_self_change(cmd_lower, writeish=writeish):
|
||||
return ToolResult(status="blocked", code="ELEVATION_BLOCKED", text="⚠️ ELEVATION_BLOCKED: the self-evolution controls (OUROBOROS_POST_TASK_EVOLUTION and OUROBOROS_EVOLUTION_PERSISTENT_OBJECTIVE) are owner-controlled — they enable or steer self-modification cycles. Change them via the owner Settings UI, or stop the agent and edit settings.json directly — the agent must not self-set evolution controls.")
|
||||
if _mentions_skill_owner_state(cmd_lower, writeish=writeish):
|
||||
return ToolResult(
|
||||
|
|
@ -728,15 +734,41 @@ def _run_shell_safety_check(
|
|||
),
|
||||
)
|
||||
|
||||
# Carry the structural write fact to the post-execution composer so a
|
||||
# permitted Pro/Cyber shell rewrite gets the same CORE_PATCH_NOTICE as an
|
||||
# editor write. Do not infer this from words in the command after the
|
||||
# fact; ``writeish`` is the shared write_shape result above.
|
||||
self._ctx._protected_shell_notice_paths = (
|
||||
[p for p in sorted(PROTECTED_RUNTIME_PATHS) if p.lower() in cmd_path_lower]
|
||||
if mode_allows_protected_write(runtime_mode) and writeish
|
||||
else []
|
||||
)
|
||||
|
||||
structural_targets = [
|
||||
str(target)
|
||||
for row in target_rows
|
||||
if len(row) > 1
|
||||
for target in (row[1] or [])
|
||||
]
|
||||
if protected_shell := registry_guards._protected_shell_block(
|
||||
self, raw_cmd, cmd_path_lower, binding, acting_self_worktree, writeish,
|
||||
runtime_mode,
|
||||
structural_targets=structural_targets,
|
||||
):
|
||||
return protected_shell
|
||||
|
||||
# GitHub repo create/delete/auth — argv-positional, never substring (#447 A7).
|
||||
from ouroboros.git_shell_policy import gh_shell_block_reason
|
||||
|
||||
if gh_block := gh_shell_block_reason(raw_cmd):
|
||||
try:
|
||||
gh_block = gh_shell_block_reason(raw_cmd, runtime_mode=runtime_mode)
|
||||
except TypeError as exc:
|
||||
# Preserve the existing injectable policy seam for callers/tests that
|
||||
# provide the legacy one-argument observer.
|
||||
if "runtime_mode" not in str(exc):
|
||||
raise
|
||||
gh_block = gh_shell_block_reason(raw_cmd)
|
||||
if gh_block:
|
||||
return ToolResult(status="blocked", code="SAFETY_VIOLATION", text=gh_block)
|
||||
|
||||
return registry_guards._shell_git_and_runtime_block(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ from typing import TYPE_CHECKING
|
|||
from ouroboros.artifacts import task_artifact_dir_path, task_id_for_artifacts
|
||||
from ouroboros.tools.tool_result import ToolResult
|
||||
from ouroboros.tools.write_shape import _no_deliverables_decision, _workspace_write_candidates
|
||||
from ouroboros.runtime_mode_policy import (
|
||||
mode_allows_protected_write,
|
||||
protected_bible_history_delete_reason,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # annotation-only imports (inert at runtime)
|
||||
from ouroboros.contracts.task_constraint import TaskConstraint
|
||||
|
|
@ -170,7 +174,10 @@ def _subagent_and_update_guard_result(
|
|||
"Nested readonly delegation is allowed only through schedule_subagent "
|
||||
"within configured depth/cap limits."
|
||||
))
|
||||
if acting_subagent and entry is not None and name not in _registry().ACTING_SUBAGENT_TOOL_NAMES:
|
||||
from ouroboros.tool_capabilities import acting_tool_names_for_context
|
||||
|
||||
acting_allowed_names = acting_tool_names_for_context(registry._ctx)
|
||||
if acting_subagent and entry is not None and name not in acting_allowed_names:
|
||||
return ToolResult(status="blocked", code="ACCESS_BLOCKED", text=(
|
||||
"⚠️ ACTING_SUBAGENT_BLOCKED: this mutative subagent may read and "
|
||||
"write inside its assigned write root and run shell/services "
|
||||
|
|
@ -880,6 +887,7 @@ def _external_shell_runtime_or_secret_block(
|
|||
|
||||
def _protected_shell_block(
|
||||
self, raw_cmd, cmd_path_lower, binding, acting_self_worktree, writeish,
|
||||
runtime_mode: str = "", *, structural_targets: list[str] | None = None,
|
||||
) -> ToolResult | None:
|
||||
"""Apply payload/core write guards to the selected physical target."""
|
||||
items = _registry()._binding_items(binding)
|
||||
|
|
@ -907,9 +915,23 @@ def _protected_shell_block(
|
|||
"payload files instead."
|
||||
),
|
||||
)
|
||||
if reason := protected_bible_history_delete_reason(
|
||||
raw_cmd, extra_paths=structural_targets or (), protect_bible=targets_system,
|
||||
identity_path=pathlib.Path(self._ctx.drive_root) / "memory" / "identity.md",
|
||||
cwd=pathlib.Path(getattr(binding, "target_path", None) or self._ctx.repo_dir),
|
||||
):
|
||||
return ToolResult(
|
||||
status="blocked",
|
||||
code="SAFETY_VIOLATION",
|
||||
text=f"⚠️ SAFETY_VIOLATION: {reason}",
|
||||
)
|
||||
if _authorized_managed_update_resolver(self._ctx):
|
||||
return None
|
||||
if targets_system and _registry().shell_writer_targets_protected(raw_cmd):
|
||||
if (
|
||||
targets_system
|
||||
and _registry().shell_writer_targets_protected(raw_cmd)
|
||||
and not mode_allows_protected_write(runtime_mode)
|
||||
):
|
||||
return ToolResult(
|
||||
status="blocked",
|
||||
code="SAFETY_VIOLATION",
|
||||
|
|
@ -919,7 +941,7 @@ def _protected_shell_block(
|
|||
+ ", ".join(sorted(_registry().PROTECTED_RUNTIME_PATHS))
|
||||
),
|
||||
)
|
||||
if targets_system:
|
||||
if targets_system and not mode_allows_protected_write(runtime_mode):
|
||||
for cf in _registry().PROTECTED_RUNTIME_PATHS_LOWER:
|
||||
# The MODE-AWARE composition fact, not the coarse legacy scan: a
|
||||
# pure read that merely mentions a protected name (`grep -n delete
|
||||
|
|
@ -1071,8 +1093,15 @@ def _workspace_shell_write_block(
|
|||
# The root's existing user_files authority is independent of cwd.
|
||||
# Acting children retain their isolated write surface in every mode.
|
||||
pro_workspace_passthrough = (
|
||||
str(runtime_mode or "").strip().lower() == "pro" and not acting_subagent
|
||||
mode_allows_protected_write(runtime_mode) and not acting_subagent
|
||||
)
|
||||
if acting_subagent:
|
||||
try:
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
pro_workspace_passthrough = runtime_mode_at_least(runtime_mode, "cyber_pro")
|
||||
except Exception:
|
||||
pass
|
||||
protected_roots = [
|
||||
getattr(self._ctx, "system_repo_dir", None) or getattr(self._ctx, "repo_dir", None),
|
||||
getattr(self._ctx, "drive_root", None),
|
||||
|
|
@ -1272,7 +1301,17 @@ def _shell_git_and_runtime_block(
|
|||
# write-aware — `is_readonly_git_command` refuses `--output=` and
|
||||
# `--no-index`, so neither a runtime write nor a settings dump
|
||||
# can ride "read-only git".
|
||||
if _registry().is_external_workspace(self._ctx) and not is_readonly_git_command(raw_cmd):
|
||||
cyber_authority = False
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least
|
||||
|
||||
cyber_authority = self._is_acting_subagent() and runtime_mode_at_least(
|
||||
get_runtime_mode(), "cyber_pro"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if _registry().is_external_workspace(self._ctx) and not is_readonly_git_command(raw_cmd) and not cyber_authority:
|
||||
if ext_block := _external_shell_runtime_or_secret_block(
|
||||
self, raw_cmd, cmd_path_lower, args, work_dir=work_dir,
|
||||
binding=binding,
|
||||
|
|
|
|||
|
|
@ -395,8 +395,13 @@ def _python_write_targets_and_unknown(inline_code: str) -> tuple[list[str], bool
|
|||
return receiver.id in str_names or receiver.id in non_path_names
|
||||
return (
|
||||
isinstance(receiver, ast.Call)
|
||||
and isinstance(receiver.func, ast.Name)
|
||||
and receiver.func.id in local_classes
|
||||
and (
|
||||
(isinstance(receiver.func, ast.Name) and receiver.func.id in local_classes)
|
||||
or (
|
||||
isinstance(receiver.func, ast.Name)
|
||||
and receiver.func.id in {"list", "tuple", "set", "dict"}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
|
|
@ -428,6 +433,17 @@ def _python_write_targets_and_unknown(inline_code: str) -> tuple[list[str], bool
|
|||
):
|
||||
non_path_names.add(bound)
|
||||
str_names.discard(bound)
|
||||
elif (
|
||||
isinstance(node.value, ast.Call)
|
||||
and isinstance(node.value.func, ast.Name)
|
||||
and node.value.func.id in {"list", "tuple", "set", "dict"}
|
||||
):
|
||||
# Built-in collection constructors produce collection receivers;
|
||||
# their ``remove``/``replace`` methods cannot mutate the file
|
||||
# system. Treat them like literal collections so a string item
|
||||
# named ``BIBLE.md`` is not promoted to a filesystem target.
|
||||
non_path_names.add(bound)
|
||||
str_names.discard(bound)
|
||||
else:
|
||||
str_names.discard(bound)
|
||||
non_path_names.discard(bound)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from ouroboros.tool_access import (
|
|||
ResolvedResourceBinding,
|
||||
build_resolved_resource_binding,
|
||||
canonical_data_root,
|
||||
load_bound_skill,
|
||||
)
|
||||
from ouroboros.tools.shell import (
|
||||
_active_subprocesses,
|
||||
|
|
@ -566,10 +567,86 @@ def _handle_list_skills(ctx: ToolContext, **_kwargs: Any) -> str:
|
|||
return json.dumps(summary, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _author_finish_existing_skill_review(
|
||||
ctx: ToolContext,
|
||||
binding: ResolvedResourceBinding,
|
||||
skill_name: str,
|
||||
*,
|
||||
disposition: str,
|
||||
rationale: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Apply an explicit advisory author finish without buying a new panel.
|
||||
|
||||
The first reviewer panel remains the source of findings. A later finish
|
||||
call may bind that evidence to the current payload hash, including after a
|
||||
local fix, once the existing deterministic preflight passes. The raw
|
||||
findings/status stay intact and no PASS is minted.
|
||||
"""
|
||||
from ouroboros.config import get_review_enforcement
|
||||
from ouroboros.review_records import build_author_disposition
|
||||
from ouroboros.skill_loader import compute_content_hash, load_review_state, save_review_state
|
||||
from ouroboros.skill_review import _run_deterministic_preflight
|
||||
|
||||
if str(get_review_enforcement() or "").strip().lower() != "advisory":
|
||||
return {"error": "SKILL_REVIEW_ERROR: explicit author finish requires advisory enforcement."}
|
||||
loaded = load_bound_skill(binding)
|
||||
if loaded is None:
|
||||
return {"error": "SKILL_REVIEW_ERROR: selected skill is unavailable for author finish."}
|
||||
current_hash = compute_content_hash(
|
||||
loaded.skill_dir,
|
||||
manifest_entry=loaded.manifest.entry,
|
||||
manifest_scripts=loaded.manifest.scripts,
|
||||
)
|
||||
drive_root = binding.state_drive_root
|
||||
review_state = load_review_state(drive_root, skill_name, skill_type=loaded.manifest.type, skill_dir=loaded.skill_dir)
|
||||
if review_state.status == "pending":
|
||||
return {"error": "SKILL_REVIEW_ERROR: existing review is pending or has no reviewer verdict."}
|
||||
if not (review_state.findings or review_state.raw_actor_records or review_state.raw_result):
|
||||
return {"error": "SKILL_REVIEW_ERROR: no prior reviewer evidence is available for author finish."}
|
||||
try:
|
||||
author_record = build_author_disposition(
|
||||
disposition=disposition,
|
||||
rationale=rationale,
|
||||
subject_hash=current_hash,
|
||||
reviewer_signal=review_state.status,
|
||||
enforcement="advisory",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"error": f"SKILL_REVIEW_ERROR: {exc}"}
|
||||
previous_hash = str(review_state.content_hash or "")
|
||||
if previous_hash != current_hash:
|
||||
# A changed payload is accepted only after the existing deterministic
|
||||
# gate checks the complete current payload. This is not a reviewer
|
||||
# PASS: the prior findings remain attached as historical evidence.
|
||||
preflight = _run_deterministic_preflight(
|
||||
ctx, drive_root, loaded, current_hash, persist=False, binding=binding,
|
||||
)
|
||||
if preflight is not None:
|
||||
return {"error": "SKILL_REVIEW_ERROR: deterministic preflight did not pass for the current payload."}
|
||||
review_state.reviewed_content_hash = previous_hash
|
||||
review_state.content_hash = current_hash
|
||||
review_state.author_disposition = author_record
|
||||
save_review_state(drive_root, skill_name, review_state)
|
||||
return {
|
||||
"skill_name": skill_name,
|
||||
"status": review_state.status,
|
||||
"content_hash": current_hash,
|
||||
"findings": list(review_state.findings or []),
|
||||
"reviewer_models": list(review_state.reviewer_models or []),
|
||||
"raw_actor_records": list(review_state.raw_actor_records or []),
|
||||
"raw_result": review_state.raw_result,
|
||||
"advisory_result": dict(review_state.advisory_result or {}),
|
||||
"author_disposition": author_record,
|
||||
"reviewed_content_hash": review_state.reviewed_content_hash,
|
||||
}
|
||||
|
||||
|
||||
def _handle_review_skill(
|
||||
ctx: ToolContext,
|
||||
skill: str = "",
|
||||
review_rebuttal: str = "",
|
||||
author_disposition: str = "",
|
||||
author_rationale: str = "",
|
||||
_resolved_binding: ResolvedResourceBinding | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
|
|
@ -591,6 +668,29 @@ def _handle_review_skill(
|
|||
_load_accepted_rebuttals,
|
||||
render_skill_review_block,
|
||||
)
|
||||
author_value = str(author_disposition or "").strip().lower()
|
||||
author_reason = " ".join(str(author_rationale or "").split()).strip()
|
||||
if author_value or author_reason:
|
||||
if author_value not in {"accepted", "rejected", "partial", "deferred"} or not author_reason:
|
||||
return "⚠️ SKILL_REVIEW_ERROR: explicit author finish requires a valid disposition and rationale."
|
||||
finished = _author_finish_existing_skill_review(
|
||||
ctx, binding, skill_name, disposition=author_value, rationale=author_reason,
|
||||
)
|
||||
if finished is None:
|
||||
return "⚠️ SKILL_REVIEW_ERROR: author finish could not bind the selected skill revision."
|
||||
if finished.get("error"):
|
||||
return str(finished["error"])
|
||||
attempt_idx = _count_attempts_for_content(
|
||||
binding.state_drive_root, skill_name, str(finished.get("content_hash") or ""),
|
||||
) or 1
|
||||
accepted_rebuttals = _load_accepted_rebuttals(binding.state_drive_root, skill_name)
|
||||
markdown = render_skill_review_block(
|
||||
finished, attempt_idx=attempt_idx, accepted_rebuttals=accepted_rebuttals,
|
||||
)
|
||||
return markdown + (
|
||||
"\n\nAuthor finish recorded for the current hash; raw reviewer findings and "
|
||||
"the prior reviewer signal remain unchanged. No reviewer PASS was fabricated."
|
||||
)
|
||||
from ouroboros.skill_review_runner import run_skill_review_lifecycle_blocking
|
||||
|
||||
def _review_with_optional_rebuttal(review_ctx: ToolContext, review_name: str):
|
||||
|
|
@ -1130,6 +1230,15 @@ _REVIEW_SCHEMA = {
|
|||
"paid-cycle ceiling."
|
||||
),
|
||||
},
|
||||
"author_disposition": {
|
||||
"type": "string",
|
||||
"enum": ["accepted", "rejected", "partial", "deferred"],
|
||||
"description": "Optional advisory author finish for this exact content hash; never a reviewer PASS and never valid for a stale or pending review.",
|
||||
},
|
||||
"author_rationale": {
|
||||
"type": "string",
|
||||
"description": "Required when author_disposition is supplied; explain why the author accepts, rejects, partially accepts, or defers the raw findings.",
|
||||
},
|
||||
},
|
||||
"required": ["skill"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ TOOL_CODE_SPECS: Mapping[str, ToolCodeSpec] = MappingProxyType(
|
|||
"blocked",
|
||||
"light_mode_blocked",
|
||||
"warning",
|
||||
"use advanced or pro mode for repository writes",
|
||||
"use advanced, pro, or cyber_pro mode for repository writes",
|
||||
),
|
||||
"WORKSPACE_GIT_REF_CHANGED": _code_spec(
|
||||
"blocked",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import re
|
|||
import tokenize
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
from ouroboros.shell_parse import shell_argv, shell_argv_with_inline, shell_argv_with_path_tokens
|
||||
from ouroboros.shell_parse import (
|
||||
shell_argv,
|
||||
shell_argv_with_inline,
|
||||
shell_argv_with_path_tokens,
|
||||
shell_command_string,
|
||||
)
|
||||
|
||||
SHELL_WRITE_INDICATORS = (
|
||||
"rm ", "rm\t", ">", "sed -i", "tee ", "truncate",
|
||||
|
|
@ -330,6 +335,34 @@ def _shell_write_indicator_scan(
|
|||
else:
|
||||
inline_bodies = frozenset()
|
||||
|
||||
# Interpreter bodies are judged structurally by their family-specific
|
||||
# parser below. Remove their source text from the coarse shell vocabulary
|
||||
# scan so a string/comment such as ``print('write_text')`` cannot masquerade
|
||||
# as a shell write channel. Keep the surrounding argv intact: redirects or
|
||||
# a real writer outside the body still remain visible to this scan.
|
||||
indicator_text = filtered_text
|
||||
raw_indicator_text = text
|
||||
interpreter_family_name = ""
|
||||
if interpreter_lane and filtered_tokens:
|
||||
from ouroboros.tools.shell_guards import interpreter_family
|
||||
|
||||
interpreter_family_name = interpreter_family(
|
||||
pathlib.PurePath(filtered_tokens[0]).name.lower().removesuffix(".exe")
|
||||
)
|
||||
shell_wrapper = bool(filtered_tokens) and pathlib.PurePath(
|
||||
filtered_tokens[0]
|
||||
).name.lower() in {"sh", "bash", "zsh"}
|
||||
if (
|
||||
interpreter_lane
|
||||
and (interpreter_family_name == "python" or shell_wrapper)
|
||||
and inline_bodies
|
||||
):
|
||||
for body in inline_bodies:
|
||||
body_lower = body.lower()
|
||||
if body_lower:
|
||||
indicator_text = indicator_text.replace(body_lower, " ")
|
||||
raw_indicator_text = raw_indicator_text.replace(body_lower, " ")
|
||||
|
||||
def _in_located_body(tok: str) -> bool:
|
||||
# Joined flags carry the body INSIDE the token (`-cBODY`, `--eval=BODY`).
|
||||
return any(body and body in tok for body in inline_bodies)
|
||||
|
|
@ -368,8 +401,8 @@ def _shell_write_indicator_scan(
|
|||
return True
|
||||
return False
|
||||
|
||||
if _indicator_hits(filtered_text, allow_bare_redirect=True) or _indicator_hits(
|
||||
text, allow_bare_redirect=False
|
||||
if _indicator_hits(indicator_text, allow_bare_redirect=True) or _indicator_hits(
|
||||
raw_indicator_text, allow_bare_redirect=False
|
||||
):
|
||||
return True
|
||||
if include_bare_open and (
|
||||
|
|
@ -383,6 +416,14 @@ def shell_has_write_indicator(raw_cmd: Any) -> bool:
|
|||
return _shell_write_indicator_scan(raw_cmd, include_bare_open=True)
|
||||
|
||||
|
||||
def _python_targets_or_unknown(body: str) -> bool:
|
||||
"""Return the structural write verdict for one Python inline body."""
|
||||
from ouroboros.tools.shell_guards import _python_write_targets_and_unknown
|
||||
|
||||
targets, unknown = _python_write_targets_and_unknown(body)
|
||||
return bool(targets or unknown)
|
||||
|
||||
|
||||
def interpreter_write_shape(raw_cmd: Any) -> bool:
|
||||
"""Mode-aware write-shape classification for an INTERPRETER command line.
|
||||
|
||||
|
|
@ -396,8 +437,46 @@ def interpreter_write_shape(raw_cmd: Any) -> bool:
|
|||
external-workspace runtime/secret read guard and the LLM safety supervisor
|
||||
stay the covering controls.
|
||||
"""
|
||||
argv = shell_argv(raw_cmd)
|
||||
if not argv:
|
||||
return False
|
||||
from ouroboros.tools.shell_guards import interpreter_family, interpreter_inline_code
|
||||
|
||||
executable = pathlib.PurePath(str(argv[0])).name.lower().removesuffix(".exe")
|
||||
if executable in {"sh", "bash", "zsh"}:
|
||||
inner = shell_command_string(argv)
|
||||
if inner:
|
||||
# Reuse the same structural classifier for a shell wrapper's body;
|
||||
# this removes prose-only Python indicators while retaining nested
|
||||
# writes and redirects.
|
||||
return interpreter_write_shape(inner)
|
||||
if _shell_write_indicator_scan(raw_cmd, include_bare_open=False, interpreter_lane=True):
|
||||
return True
|
||||
family = interpreter_family(executable)
|
||||
if family:
|
||||
bodies = interpreter_inline_code(argv)
|
||||
if bodies and family == "python":
|
||||
# Python bodies have a real AST target walk. It distinguishes a
|
||||
# call/comment/string containing a writer word from an actual write,
|
||||
# while unknown execution remains write-capable (fail closed).
|
||||
return any(
|
||||
bool(_python_targets_or_unknown(body)) for body in bodies
|
||||
)
|
||||
if bodies and family in {"node", "ruby"}:
|
||||
# Keep the shared regex as a fallback for native idioms whose
|
||||
# literal-target extractor cannot resolve a variable destination
|
||||
# (for example Ruby IO.binwrite or a destructured Node writer).
|
||||
if any(
|
||||
bool(script_literal_write_targets_and_unknown(family, body)[0])
|
||||
or script_literal_write_targets_and_unknown(family, body)[1]
|
||||
for body in bodies
|
||||
):
|
||||
return True
|
||||
|
||||
# A non-inline script path (and Perl, whose syntax is intentionally not
|
||||
# parsed here) keeps the established conservative vocabulary. Inline
|
||||
# Python/Node/Ruby bodies returned above never reach this text search, so
|
||||
# their prose cannot reintroduce the false positive.
|
||||
if isinstance(raw_cmd, list):
|
||||
text = " ".join(str(x) for x in raw_cmd)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ def compose_workspace_block(
|
|||
"Use read_file, write_file, list_files, search_code, vcs_status, vcs_diff, and run_command against this target workspace, not the Ouroboros system repo.\n"
|
||||
f"{render_workspace_preflight_summary(workspace_preflight)}\n"
|
||||
"Before editing, account for target-repo docs or root-level instructions if present.\n"
|
||||
"Project-local dependency installs are allowed in external workspace tasks; system/global installs are for runtime_mode=pro only and must be noninteractive.\n"
|
||||
"Project-local dependency installs are allowed in external workspace tasks; system/global installs are for runtime_mode=pro or cyber_pro and must be noninteractive.\n"
|
||||
"When work naturally splits into independent branches, or while a long build/download/test is running, use schedule_subagent for a focused parallel handoff instead of serializing every branch yourself.\n"
|
||||
"Before finalizing, re-read the original task and verify each explicit requirement through the interface/path/format/service the task names; do not treat a weaker surrogate self-test as completion.\n"
|
||||
"Final summaries belong in the final answer, not new repo markdown files unless requested.\n"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ DANGEROUS — block only these:
|
|||
- Destructive commands targeting system or home root: rm -rf /, rm -rf ~, disk format/wipe, deleting system directories (/System, /usr, /bin, /etc, C:\Windows)
|
||||
- Commands that make the OS unbootable or kill critical system processes irreversibly
|
||||
- HTTP requests (curl, wget, python, etc.) that send API keys, tokens, or credentials to non-localhost external URLs
|
||||
- Modifying or deleting any protected Ouroboros runtime path outside `runtime_mode=pro` + normal commit review. Protected paths (exact mirror of `PROTECTED_RUNTIME_PATHS` plus the protected prefix in `ouroboros/runtime_mode_policy.py`): `BIBLE.md`, `ouroboros/safety.py`, `ouroboros/runtime_mode_policy.py`, `ouroboros/tools/registry.py`, `ouroboros/tools/extension_dispatch.py`, `prompts/SAFETY.md`, `ouroboros/tools/registry_core.py`, `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/tools/tool_result.py`, `ouroboros/gateway/contracts.py`, `docs/CHECKLISTS.md`, `docs/CHECKLISTS_ARCHIVE.md`, `tests/test_contracts.py`, `ouroboros/size_ratchet_manifest.py`, `.github/workflows/ci.yml`, `Ouroboros.spec`, `build.sh`, `build_linux.sh`, `build_windows.ps1`, `scripts/build_repo_bundle.py`, `ouroboros/launcher_bootstrap.py`, `ouroboros/repo_remotes.py`, `supervisor/git_ops.py`, `supervisor/update_merge.py`, `supervisor/update_merge_policy.py`, `supervisor/git_ops_remotes.py`, `supervisor/git_ops_rescue.py`, `supervisor/git_ops_reset.py`, `supervisor/git_ops_updates.py`, `supervisor/update_candidate.py`, `supervisor/update_carriers.py`, `supervisor/update_merge_plan.py`, and everything under `ouroboros/contracts/`
|
||||
- Modifying protected Ouroboros runtime paths is allowed in `runtime_mode=pro` or `runtime_mode=cyber_pro` with the normal commit review notice. Deleting BIBLE.md or its git history remains prohibited. Protected paths (exact mirror of `PROTECTED_RUNTIME_PATHS` plus the protected prefix in `ouroboros/runtime_mode_policy.py`): `BIBLE.md`, `ouroboros/safety.py`, `ouroboros/runtime_mode_policy.py`, `ouroboros/tools/registry.py`, `ouroboros/tools/extension_dispatch.py`, `prompts/SAFETY.md`, `ouroboros/tools/registry_core.py`, `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/tools/tool_result.py`, `ouroboros/gateway/contracts.py`, `docs/CHECKLISTS.md`, `docs/CHECKLISTS_ARCHIVE.md`, `tests/test_contracts.py`, `ouroboros/size_ratchet_manifest.py`, `.github/workflows/ci.yml`, `Ouroboros.spec`, `build.sh`, `build_linux.sh`, `build_windows.ps1`, `scripts/build_repo_bundle.py`, `ouroboros/launcher_bootstrap.py`, `ouroboros/repo_remotes.py`, `supervisor/git_ops.py`, `supervisor/update_merge.py`, `supervisor/update_merge_policy.py`, `supervisor/git_ops_remotes.py`, `supervisor/git_ops_rescue.py`, `supervisor/git_ops_reset.py`, `supervisor/git_ops_updates.py`, `supervisor/update_candidate.py`, `supervisor/update_carriers.py`, `supervisor/update_merge_plan.py`, and everything under `ouroboros/contracts/`
|
||||
- Any tool call through which the agent WRITES one of its own owner-only controls — by writing settings.json (directly or via `ouroboros.config.save_settings(...)` from a subprocess), POSTing to `/api/settings` or an `/api/owner/*` endpoint (`/api/owner/runtime-mode`, `/api/owner/context-mode`, `/api/owner/safety-mode`, …), invoking the `ouroboros settings` CLI, or driving the UI/loopback API from browser-automation JS. The controls, each with the direction the agent must never take on its own behalf: `OUROBOROS_RUNTIME_MODE` (raising its own privilege scope); `OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS` (granting its own subagents write power); `OUROBOROS_CONTEXT_MODE` (lowering its owner-selected cognitive horizon — since v6.80.0 `low` also declares the BIBLE P3 blocking scope review not performed, so this weakens the immune system too); `OUROBOROS_SAFETY_MODE` (lowering its own LLM-safety coverage; the deterministic sandbox always stays on); `OUROBOROS_POST_TASK_EVOLUTION` and `OUROBOROS_EVOLUTION_PERSISTENT_OBJECTIVE` (self-setting its own evolution controls); and `POST /api/owner/skills/<skill>/attest-review` (self-attesting a skill to skip the immune system's review). READING any of these values (e.g. `grep` on settings.json) is not dangerous. The legitimate path is always the OWNER acting: the dedicated owner endpoint, the Settings UI, or stopping the agent, editing settings.json directly, and restarting — never the agent on its own behalf, through any channel (subprocess, HTTP, CLI, browser automation, MCP or extension tool).
|
||||
|
||||
For skill lifecycle actions, the agent may relay an already expressed owner instruction through `skill_owner_action`, which calls the same lifecycle handlers as the owner UI and CLI. The acting model must interpret the actual owner words for the selected skill and action; a source identifier, an unrelated owner message, or a routine Repair request is not permission to grant access, attest, or delete. Attestation requires the owner to request skipping the expensive skill review; self-attestation on the agent's own behalf remains DANGEROUS. The host checks source provenance, caller, selected revision and existing skill prerequisites; it does not infer semantic consent from an identifier. Ordinary and automatic Repair retain their original task scope. This does not authorize changing any other owner-only control.
|
||||
|
|
@ -28,7 +28,7 @@ SAFE — verdict SAFE, never flag as DANGEROUS or SUSPICIOUS:
|
|||
|
||||
`pip install` / `pip uninstall` / `npm install` mutate the environment and intentionally reach you — default to SAFE for normal dev packages, but flag SUSPICIOUS if the installed package looks exfiltrating, typosquatted, or otherwise odd.
|
||||
|
||||
External workspace tasks may need dependency installation to validate or solve the target project. Project-local installs are normal development work. In `runtime_mode=pro`, system/global installs and installer scripts may also be SAFE when they are plausibly needed for the external workspace task; keep flagging exfiltrating, destructive, or suspicious package/script behavior. `sudo` must be noninteractive (`sudo -n`); password-prompting sudo is blocked before this review and should be treated as environment-blocked, not worked around.
|
||||
External workspace tasks may need dependency installation to validate or solve the target project. Project-local installs are normal development work. In `runtime_mode=pro` or `runtime_mode=cyber_pro`, system/global installs and installer scripts may also be SAFE when they are plausibly needed for the external workspace task; keep flagging exfiltrating, destructive, or suspicious package/script behavior. `sudo` must be noninteractive (`sudo -n`); password-prompting sudo is blocked before this review and should be treated as environment-blocked, not worked around.
|
||||
|
||||
Note: this SAFE bucket describes the verdicts you should return. It is NOT a statement about which calls actually reach you — that is governed by `ouroboros/safety.py::TOOL_POLICY`. Most trusted built-ins (file/context tools, knowledge and memory tools, read-only VCS, reviewed commit gates, task/review status, service status/log reads, web_search, browse_page, etc.) have `POLICY_SKIP` and never reach you. The tools that DO reach you are: `POLICY_CHECK` tools (PR integration flow, CI, GitHub writes, `skill_exec`, `skill_owner_action`, `integrate_subagent_patch` / `integrate_delegated_patch`, `generate_evolution_stats`, `submit_skill_to_hub`, and reviewed extension tools that fall through policy); every MCP tool (`mcp_<server>__<tool>`), which has no deterministic pre-scan, so you are its only gate for the owner-control writes above; and the `POLICY_CHECK_CONDITIONAL` process tools `run_command`, `run_script`, `start_service`, and `verify_and_record` (whose declared verification `check` is run like a command) — for these, deterministic safe-subject commands may be whitelisted before this review, and non-whitelisted shell/script/service/check subjects reach you. Long-running services are still process subjects: allow normal dev servers, but flag clearly destructive, exfiltrating, or protected-path behavior. For calls that reach you, the guidance above is what you should output.
|
||||
|
||||
|
|
|
|||
|
|
@ -295,9 +295,10 @@ instead of repeating.
|
|||
## Safety and Constraints
|
||||
|
||||
Every tool call crosses the deterministic gates (`registry.py`, the resource
|
||||
roots, `runtime_mode_policy.py`): protected runtime paths, mutating shell git
|
||||
aimed at the Ouroboros runtime, and GitHub repo/auth manipulation are refused,
|
||||
and no prompt or model output argues them away. Calls selected by policy also
|
||||
roots, `runtime_mode_policy.py`): ordinary modes retain protected-path,
|
||||
mutating-shell-git and GitHub repo/auth boundaries, while `runtime_mode=pro` /
|
||||
`cyber_pro` use the reviewed protected-rewrite and owner-setup seams. No prompt
|
||||
or model output argues a retained prohibition away. Calls selected by policy also
|
||||
cross the LLM safety supervisor (`safety.py` with `prompts/SAFETY.md`) under
|
||||
the owner-selected safety mode: tools whose policy is `check`, the
|
||||
`check_conditional` process tools whenever the command is outside the
|
||||
|
|
@ -309,7 +310,7 @@ find a safer way to the goal. `SAFETY_UNAVAILABLE` — blocked without a verdict
|
|||
because the supervisor was rate-limited past its retry; retry later or report
|
||||
it, never reword a benign command to slip past (a transport failure in the
|
||||
remote lane still surfaces as `SAFETY_VIOLATION` with its reason line — read
|
||||
it before acting). `CORE_PATCH_NOTICE` — a pro-mode edit of
|
||||
it before acting). `CORE_PATCH_NOTICE` — a pro/cyber_pro edit of
|
||||
a protected path is on disk and still lands only through the normal reviewed
|
||||
commit. When the supervisor degrades to a warning instead of blocking is the
|
||||
documented contract in `docs/ARCHITECTURE.md` "Safety and runtime mode".
|
||||
|
|
@ -317,12 +318,15 @@ documented contract in `docs/ARCHITECTURE.md` "Safety and runtime mode".
|
|||
Bypassing, disabling, or ignoring the Safety Agent or `BIBLE.md` is forbidden,
|
||||
and so is modifying my own context to "forget" the Constitution (P1). LLM
|
||||
safety coverage (`OUROBOROS_SAFETY_MODE`), context mode, runtime mode, the
|
||||
mutative-subagent gate, and the evolution controls are owner-only: lowering my
|
||||
own supervision to remove friction is forbidden self-modification (BIBLE P3).
|
||||
mutative-subagent gate, and the evolution controls remain owner-only in ordinary
|
||||
modes. An owner-selected `cyber_pro` task may change configured policy through the
|
||||
existing audited settings seam; its effective snapshot and restart/next-task
|
||||
boundary remain visible and durable.
|
||||
|
||||
Secrets are env variables. I do not print them to chat, logs, commits, or
|
||||
files, do not share them with third parties, and do not run `env` or other
|
||||
commands that expose them.
|
||||
Secrets remain protected from unauthorized publication. When my human supplies a
|
||||
credential for a selected Cyber Pro task, the chosen model/tool and that task’s
|
||||
local trace may receive the literal value; unrelated destinations and public
|
||||
exports still require an explicit visible action.
|
||||
|
||||
Constraints: I do not change repository settings (visibility, collaborators)
|
||||
without explicit permission from my human.
|
||||
|
|
@ -349,7 +353,7 @@ The safety-critical set (matching `runtime_mode_policy.SAFETY_CRITICAL_PATHS`):
|
|||
— these plus the frozen contracts and the release/managed-repo invariants — is
|
||||
defined in `ouroboros/runtime_mode_policy.py`, and the gate names the path when
|
||||
it refuses. Advanced mode may evolve the application layer but not that
|
||||
surface; pro mode may edit it on disk, and the change still lands only through
|
||||
surface; pro/cyber_pro mode may edit it on disk, and the change still lands only through
|
||||
the normal reviewed commit — triad plus the scope review where the owner's
|
||||
context mode applies it (Low records a typed skip).
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,86 @@ def test_profile_normal_task_is_self_modification(tmp_path):
|
|||
assert active_tool_profile(ctx) == "self_modification"
|
||||
|
||||
|
||||
def _enable_cyber_mode_for_test(monkeypatch):
|
||||
import ouroboros.config as config
|
||||
import ouroboros.runtime_mode_policy as policy
|
||||
import ouroboros.settings_scales as scales
|
||||
|
||||
monkeypatch.setattr(scales, "VALID_RUNTIME_MODES", (*scales.VALID_RUNTIME_MODES, "cyber_pro"))
|
||||
monkeypatch.setattr(config, "VALID_RUNTIME_MODES", (*config.VALID_RUNTIME_MODES, "cyber_pro"))
|
||||
monkeypatch.setitem(policy._RUNTIME_MODE_RANK, "cyber_pro", 3)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "cyber_pro")
|
||||
|
||||
|
||||
def test_cyber_acting_child_inherits_owner_resource_matrix(tmp_path, monkeypatch):
|
||||
from ouroboros.tool_access import decide_tool_access
|
||||
|
||||
_enable_cyber_mode_for_test(monkeypatch)
|
||||
ctx = _profile_ctx(
|
||||
tmp_path,
|
||||
constraint=TaskConstraint(mode="acting_subagent", surface="external_workspace"),
|
||||
)
|
||||
assert active_tool_profile(ctx) == "acting_subagent"
|
||||
assert decide_tool_access(
|
||||
profile="acting_subagent", root="user_files", operation="write",
|
||||
).allow
|
||||
assert decide_tool_access(
|
||||
profile="acting_subagent", root="task_drive", operation="shell",
|
||||
).allow
|
||||
assert not decide_tool_access(
|
||||
profile="local_readonly_subagent", root="user_files", operation="write",
|
||||
).allow
|
||||
|
||||
|
||||
def test_cyber_acting_child_can_use_owner_credential_file_path(tmp_path, monkeypatch):
|
||||
from ouroboros.tool_access_user_files import user_files_path_block_reason
|
||||
|
||||
_enable_cyber_mode_for_test(monkeypatch)
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("OUROBOROS_USER_FILES_ROOT", str(home))
|
||||
ctx = _profile_ctx(
|
||||
tmp_path,
|
||||
constraint=TaskConstraint(mode="acting_subagent", surface="external_workspace"),
|
||||
)
|
||||
credential = home / ".ssh" / "id_rsa"
|
||||
credential.parent.mkdir()
|
||||
credential.write_text("owner key", encoding="utf-8")
|
||||
assert user_files_path_block_reason(ctx, credential, operation="write") == ""
|
||||
readonly_root = tmp_path / "readonly"
|
||||
readonly_root.mkdir()
|
||||
readonly = _profile_ctx(
|
||||
readonly_root,
|
||||
constraint=TaskConstraint(mode="local_readonly_subagent"),
|
||||
)
|
||||
assert user_files_path_block_reason(readonly, credential, operation="write")
|
||||
|
||||
|
||||
def test_cyber_acting_registry_exposes_review_skill_and_runtime_tools(tmp_path, monkeypatch):
|
||||
_enable_cyber_mode_for_test(monkeypatch)
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
ctx = _profile_ctx(
|
||||
tmp_path,
|
||||
constraint=TaskConstraint(
|
||||
mode="acting_subagent", surface="external_workspace", write_root=str(workspace),
|
||||
),
|
||||
)
|
||||
reg = ToolRegistry(repo_dir=ctx.repo_dir, drive_root=ctx.drive_root)
|
||||
reg._ctx = ctx
|
||||
|
||||
names = set(reg.initial_tool_names())
|
||||
assert {"review_status", "plan_task", "skill_review", "skill_exec", "toggle_evolution"} <= names
|
||||
assert "commit_reviewed" not in names and "vcs_commit_reviewed" not in names
|
||||
schemas = {
|
||||
item["function"]["name"] for item in reg.schemas()
|
||||
if item.get("function")
|
||||
}
|
||||
assert {"review_status", "plan_task", "skill_review", "skill_exec"} <= schemas
|
||||
assert reg.get_schema_by_name("review_status") is not None
|
||||
assert "TOOL_ACCESS_BLOCKED" not in reg.execute("review_status", {})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Registry gating for acting subagents
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -34,6 +34,24 @@ def _attach_dir(drive, task_id):
|
|||
|
||||
|
||||
class TestStageTaskAttachments:
|
||||
def test_cyber_owner_attachment_can_stage_credential_named_source(self, tmp_path, monkeypatch):
|
||||
from ouroboros import config
|
||||
from ouroboros.artifacts import stage_task_attachments
|
||||
from ouroboros.runtime_mode_policy import _RUNTIME_MODE_RANK
|
||||
|
||||
monkeypatch.setattr(config, "get_runtime_mode", lambda: "cyber_pro")
|
||||
monkeypatch.setitem(_RUNTIME_MODE_RANK, "cyber_pro", 3)
|
||||
drive = _drive(tmp_path)
|
||||
src = tmp_path / ".ssh" / "id_rsa"
|
||||
src.parent.mkdir()
|
||||
src.write_text("owner key", encoding="utf-8")
|
||||
|
||||
manifest = stage_task_attachments(drive, "cyber-task", [{"path": str(src)}])
|
||||
|
||||
assert manifest[0]["status"] == "staged"
|
||||
staged = _attach_dir(drive, "cyber-task") / manifest[0]["relpath"].split("/", 1)[1]
|
||||
assert staged.read_text(encoding="utf-8") == "owner key"
|
||||
|
||||
def test_stages_into_artifact_store(self, tmp_path):
|
||||
from ouroboros.artifacts import stage_task_attachments
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path):
|
|||
version = (pathlib.Path(__file__).resolve().parent.parent / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert f"GATEWAY_CONTRACT_VERSION = '{version}'" in text
|
||||
settings_meta_fields = {
|
||||
"custom_secret_keys", "setup_contract", "available_subagents",
|
||||
"custom_secret_keys", "setup_contract", "available_subagents", "policy_state",
|
||||
}
|
||||
assert settings_meta_fields <= set(SettingsMeta.__annotations__)
|
||||
assert _js_typedef_fields(text, "SettingsMeta") == settings_meta_fields
|
||||
|
|
|
|||
|
|
@ -114,10 +114,9 @@ def test_every_host_acceptance_writer_emits_a_canonical_status_and_typed_reason(
|
|||
i for i, line in enumerate(src)
|
||||
if "_set_acceptance_decision(" in line and not line.lstrip().startswith("def ")
|
||||
]
|
||||
# 19th writer (F6 upstream sync): the A-material identical-acceptance
|
||||
# refusal joins the forced-rail bypass recorder and the forced
|
||||
# children_unabsorbed terminalizer.
|
||||
assert len(starts) == 19, f"writer inventory changed: {len(starts)} call sites"
|
||||
# 20th writer: advisory author-finality records an honest terminal decision
|
||||
# after the first host panel without manufacturing reviewer PASS.
|
||||
assert len(starts) == 20, f"writer inventory changed: {len(starts)} call sites"
|
||||
allowed_status = {
|
||||
"ACCEPTANCE_ACCEPTED", "ACCEPTANCE_REVISION_REQUESTED",
|
||||
"ACCEPTANCE_FINALIZED_UNACCEPTED",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ _REGISTRY_GUARD_SIGNATURES = {
|
|||
"_command_mentions_protected_root": "(cmd_path_lower: 'str', root_text: 'str') -> 'bool'",
|
||||
"_authorized_managed_update_resolver": "(ctx: 'Any') -> 'bool'",
|
||||
"_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'",
|
||||
"_protected_shell_block": "(self, raw_cmd, cmd_path_lower, binding, acting_self_worktree, writeish) -> 'ToolResult | None'",
|
||||
"_protected_shell_block": "(self, raw_cmd, cmd_path_lower, binding, acting_self_worktree, writeish, runtime_mode: 'str' = '', *, structural_targets: 'list[str] | None' = None) -> 'ToolResult | None'",
|
||||
"_git_protected_roots": "(self) -> 'list'",
|
||||
"_resolved_shell_cwd": "(self, args: 'Dict[str, Any]', binding: 'Any' = None) -> 'pathlib.Path | ToolResult'",
|
||||
"_external_workspace_git_block": "(self, raw_cmd: 'Any', work_dir: 'pathlib.Path') -> 'ToolResult | None'",
|
||||
|
|
@ -216,7 +216,7 @@ def test_process_guard_uses_explicit_registry_guard_owners_once_in_order(
|
|||
calls.append("_workspace_shell_write_block")
|
||||
return denial if stop_index == 1 else None
|
||||
|
||||
def protected(owner, *args):
|
||||
def protected(owner, *args, **kwargs):
|
||||
assert owner is stub
|
||||
calls.append("_protected_shell_block")
|
||||
return denial if stop_index == 2 else None
|
||||
|
|
|
|||
239
tests/test_review_author_finality.py
Normal file
239
tests/test_review_author_finality.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import json
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_commit_attempt_does_not_infer_author_finish_from_success():
|
||||
from ouroboros.tools import commit_gate
|
||||
|
||||
source = inspect.getsource(commit_gate._record_commit_attempt)
|
||||
assert 'author_disposition = _req("author_disposition", None)' in source
|
||||
assert 'if author_disposition is None and status == "succeeded"' not in source
|
||||
assert 'disposition="accepted"' not in source
|
||||
|
||||
|
||||
def test_author_disposition_is_hash_bound_and_rejects_malformed():
|
||||
from ouroboros.review_records import (
|
||||
build_author_disposition,
|
||||
validate_author_disposition,
|
||||
)
|
||||
|
||||
record = build_author_disposition(
|
||||
disposition="rejected",
|
||||
rationale="The remaining note is outside this task.",
|
||||
subject_hash="abc123",
|
||||
reviewer_signal="REVISE_PLAN",
|
||||
enforcement="advisory",
|
||||
)
|
||||
assert record["subject_hash"] == "abc123"
|
||||
assert validate_author_disposition(record, subject_hash="abc123") == record
|
||||
assert validate_author_disposition(record, subject_hash="stale") is None
|
||||
with pytest.raises(ValueError, match="rationale"):
|
||||
build_author_disposition(
|
||||
disposition="accepted", rationale="", subject_hash="abc123",
|
||||
)
|
||||
|
||||
|
||||
def test_plan_author_finish_is_projected_without_closing_blocking_gate():
|
||||
from ouroboros.task_results import _validated_plan_review_state
|
||||
|
||||
state = {
|
||||
"schema_version": 2,
|
||||
"series_id": "s",
|
||||
"cycles_paid": 1,
|
||||
"need_evidence_seen": [],
|
||||
"current_attempt": {"fingerprint": "a" * 64, "status": "open", "reason": ""},
|
||||
"waves": [{
|
||||
"request_fingerprint": "a" * 64,
|
||||
"aggregate": "REVISE_PLAN",
|
||||
"closed": False,
|
||||
"spec": {"goal": "g"},
|
||||
"findings": [{"finding_id": "f", "class": "blocking"}],
|
||||
"dispositions": [],
|
||||
"author_disposition": {
|
||||
"disposition": "rejected",
|
||||
"rationale": "The reviewer request is out of scope.",
|
||||
"subject_hash": "a" * 64,
|
||||
"reviewer_signal": "REVISE_PLAN",
|
||||
"enforcement": "advisory",
|
||||
"recorded_at": "2026-01-01T00:00:00Z",
|
||||
"source": "author",
|
||||
},
|
||||
}],
|
||||
}
|
||||
loaded = _validated_plan_review_state(state)
|
||||
assert loaded["waves"][0]["author_disposition"]["disposition"] == "rejected"
|
||||
assert loaded["waves"][0]["closed"] is False
|
||||
bad = json.loads(json.dumps(state))
|
||||
bad["waves"][0]["author_disposition"]["subject_hash"] = "b" * 64
|
||||
with pytest.raises(ValueError, match="author_disposition"):
|
||||
_validated_plan_review_state(bad)
|
||||
|
||||
|
||||
def test_skill_review_state_round_trips_current_hash_author_finish(tmp_path):
|
||||
from ouroboros.skill_loader import SkillReviewState, load_review_state, save_review_state
|
||||
|
||||
state = SkillReviewState(
|
||||
status="blockers",
|
||||
content_hash="c" * 64,
|
||||
findings=[{"item": "bug_hunting", "verdict": "FAIL", "severity": "advisory"}],
|
||||
author_disposition={
|
||||
"disposition": "partial",
|
||||
"rationale": "The advisory finding is understood and accepted for this revision.",
|
||||
"subject_hash": "c" * 64,
|
||||
"reviewer_signal": "blockers",
|
||||
"enforcement": "advisory",
|
||||
"recorded_at": "2026-01-01T00:00:00Z",
|
||||
"source": "author",
|
||||
},
|
||||
)
|
||||
save_review_state(tmp_path, "demo", state)
|
||||
loaded = load_review_state(tmp_path, "demo")
|
||||
assert loaded.author_disposition["subject_hash"] == "c" * 64
|
||||
assert loaded.to_dict()["author_disposition"]["disposition"] == "partial"
|
||||
|
||||
|
||||
def test_advisory_acceptance_author_finish_skips_improvement_capsule(monkeypatch, tmp_path):
|
||||
import ouroboros.loop as loop_mod
|
||||
import ouroboros.review_substrate as substrate
|
||||
from ouroboros.loop_acceptance_review import _apply_task_acceptance_result
|
||||
|
||||
monkeypatch.setattr(loop_mod, "get_review_enforcement", lambda: "advisory")
|
||||
monkeypatch.setattr(loop_mod, "_end_task_acceptance_fence", lambda *_a, **_k: True)
|
||||
monkeypatch.setattr(loop_mod, "_mark_root_acceptance_checkpoint", lambda *_a, **_k: None)
|
||||
tool_ctx = type("Ctx", (), {
|
||||
"_task_acceptance_reviewed": False,
|
||||
"_task_acceptance_improvement_passes": 0,
|
||||
"_task_acceptance_seen_bindings": {},
|
||||
})()
|
||||
ctx = loop_mod._TaskAcceptanceContext(
|
||||
tools=type("Tools", (), {"_ctx": tool_ctx})(),
|
||||
content="done", task_id="t", task_type="task",
|
||||
llm_trace={"tool_calls": [], "acceptance_decision": {
|
||||
"agent_disposition": "rejected",
|
||||
"agent_rationale": "The reviewer suggestion is outside scope.",
|
||||
}},
|
||||
drive_root=None, messages=[], emit_progress=lambda *_a, **_k: None,
|
||||
mode="required", subtree_statuses=[], budget_profile={"max_improvement_passes": 3},
|
||||
passes_done=0,
|
||||
)
|
||||
result = substrate.ReviewRunResult(
|
||||
request={"surface": "task_acceptance", "policy": {"min_successful_slots": 1}},
|
||||
actors=[{"slot_id": "s0", "signal": "FAIL", "parsed": {
|
||||
"verdict": "FAIL", "outcome_tier": "best_effort",
|
||||
"completion_coach": "make a change",
|
||||
}}],
|
||||
parsed_findings=[], aggregate_signal="FAIL",
|
||||
)
|
||||
assert _apply_task_acceptance_result(ctx, result, record_run=False) is False
|
||||
decision = ctx.llm_trace["acceptance_decision"]
|
||||
assert decision["reason"] == "author_finish"
|
||||
assert decision["status"] == "finalized_unaccepted"
|
||||
assert decision["author_disposition"]["disposition"] == "rejected"
|
||||
assert decision["author_disposition"]["subject_hash"]
|
||||
assert tool_ctx._task_acceptance_reviewed is True
|
||||
|
||||
|
||||
def test_skill_author_finish_uses_existing_review_without_dispatch_same_hash(
|
||||
monkeypatch, tmp_path,
|
||||
):
|
||||
from ouroboros.skill_loader import SkillReviewState, compute_content_hash, save_review_state
|
||||
from ouroboros.tool_access_types import ResolvedResourceBinding
|
||||
from ouroboros.tools import skill_exec as skill_exec_mod
|
||||
from tests.test_skill_exec import _build_skill, _make_ctx
|
||||
|
||||
skills_root = tmp_path / "skills"
|
||||
skill_dir = _build_skill(skills_root, "demo")
|
||||
ctx = _make_ctx(tmp_path)
|
||||
binding = ResolvedResourceBinding(
|
||||
profile="self_modification", root="skill_payload", operation="review",
|
||||
base_path=skill_dir, target_path=skill_dir, source="test",
|
||||
skill_name="demo", state_drive_root=tmp_path,
|
||||
)
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
|
||||
monkeypatch.setattr(skill_exec_mod, "build_resolved_resource_binding", lambda *a, **k: binding)
|
||||
monkeypatch.setattr(skill_exec_mod, "_skill_tool_preflight", lambda *a, **k: "")
|
||||
prior_hash = compute_content_hash(skill_dir)
|
||||
save_review_state(tmp_path, "demo", SkillReviewState(
|
||||
status="blockers", content_hash=prior_hash,
|
||||
findings=[{"item": "bug_hunting", "verdict": "FAIL", "severity": "critical", "reason": "review finding"}],
|
||||
raw_actor_records=[{"slot_id": "s0", "status": "ok"}],
|
||||
))
|
||||
monkeypatch.setattr(
|
||||
skill_exec_mod, "run_skill_review_lifecycle_blocking",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("author finish dispatched a new panel")),
|
||||
raising=False,
|
||||
)
|
||||
out = skill_exec_mod._handle_review_skill(
|
||||
ctx, skill="demo", _resolved_binding=binding,
|
||||
author_disposition="rejected", author_rationale="The finding is outside this task.",
|
||||
)
|
||||
assert "Author finish recorded" in out
|
||||
assert "review finding" in out
|
||||
loaded = __import__("ouroboros.skill_loader", fromlist=["load_review_state"]).load_review_state(tmp_path, "demo")
|
||||
assert loaded.author_disposition["subject_hash"] == prior_hash
|
||||
assert loaded.status == "blockers"
|
||||
|
||||
|
||||
def test_skill_author_finish_binds_changed_hash_after_preflight_without_panel(
|
||||
monkeypatch, tmp_path,
|
||||
):
|
||||
from ouroboros.skill_loader import SkillReviewState, compute_content_hash, load_review_state, save_review_state
|
||||
from ouroboros.tool_access_types import ResolvedResourceBinding
|
||||
from ouroboros.tools import skill_exec as skill_exec_mod
|
||||
from tests.test_skill_exec import _build_skill, _make_ctx
|
||||
|
||||
skills_root = tmp_path / "skills"
|
||||
skill_dir = _build_skill(skills_root, "demo", script_body="print('old')\n")
|
||||
ctx = _make_ctx(tmp_path)
|
||||
binding = ResolvedResourceBinding(
|
||||
profile="self_modification", root="skill_payload", operation="review",
|
||||
base_path=skill_dir, target_path=skill_dir, source="test",
|
||||
skill_name="demo", state_drive_root=tmp_path,
|
||||
)
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
|
||||
monkeypatch.setattr(skill_exec_mod, "build_resolved_resource_binding", lambda *a, **k: binding)
|
||||
monkeypatch.setattr(skill_exec_mod, "_skill_tool_preflight", lambda *a, **k: "")
|
||||
old_hash = compute_content_hash(skill_dir)
|
||||
save_review_state(tmp_path, "demo", SkillReviewState(
|
||||
status="blockers", content_hash=old_hash,
|
||||
findings=[{"item": "bug_hunting", "verdict": "FAIL", "severity": "critical", "reason": "old finding"}],
|
||||
raw_actor_records=[{"slot_id": "s0", "status": "ok"}],
|
||||
))
|
||||
(skill_dir / "scripts" / "hello.py").write_text("print('fixed')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
skill_exec_mod, "run_skill_review_lifecycle_blocking",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("changed-hash finish dispatched a panel")),
|
||||
raising=False,
|
||||
)
|
||||
out = skill_exec_mod._handle_review_skill(
|
||||
ctx, skill="demo", _resolved_binding=binding,
|
||||
author_disposition="partial", author_rationale="The fix addresses the actionable part.",
|
||||
)
|
||||
current_hash = compute_content_hash(skill_dir)
|
||||
loaded = load_review_state(tmp_path, "demo")
|
||||
assert current_hash != old_hash
|
||||
assert loaded.content_hash == current_hash
|
||||
assert loaded.reviewed_content_hash == old_hash
|
||||
assert loaded.author_disposition["subject_hash"] == current_hash
|
||||
assert loaded.status == "blockers"
|
||||
assert "raw reviewer findings" in out
|
||||
|
||||
|
||||
def test_ordinary_advisory_commit_does_not_invent_author_finish(tmp_path):
|
||||
from types import SimpleNamespace
|
||||
from ouroboros.review_state import load_state
|
||||
from ouroboros.tools.commit_gate import _record_commit_attempt
|
||||
|
||||
ctx = SimpleNamespace(
|
||||
drive_root=tmp_path,
|
||||
repo_dir=tmp_path,
|
||||
task_id="",
|
||||
_review_advisory=["advisory finding"],
|
||||
_current_review_attempt_number=0,
|
||||
)
|
||||
_record_commit_attempt(ctx, commit_message="ordinary advisory", status="succeeded")
|
||||
attempts = load_state(tmp_path).attempts
|
||||
assert attempts
|
||||
assert attempts[-1].author_disposition == {}
|
||||
|
|
@ -403,7 +403,9 @@ def test_skill_review_contract_fingerprint_preserves_legacy_and_tracks_rows(monk
|
|||
|
||||
monkeypatch.setenv("OUROBOROS_EFFORT_REVIEW", "high")
|
||||
legacy = skill_review_contract_fingerprint(["m1", "m2"], required_items=("a",))
|
||||
assert legacy == "eb35c9d2d6daaf1afdece2baec2107aff2b8107c80ab2788597a8c55545a215a"
|
||||
# The author-finality contract is part of the skill-review prompt contract;
|
||||
# its deliberate wording change invalidates the old fingerprint.
|
||||
assert legacy == "1572e2c1d4da4ed95c8d58087ad8e2f0834a6d892bc79f782286b32f21c9d848"
|
||||
legacy_delivery = {
|
||||
"legacy_skill_fingerprint": True,
|
||||
"models": ["m1", "m2"], "routes": ["api_chat", "api_chat"],
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ def test_llm_internal_fallbacks_follow_shipped_model_defaults(monkeypatch):
|
|||
def test_valid_runtime_modes_is_frozen_tuple():
|
||||
from ouroboros.config import VALID_RUNTIME_MODES
|
||||
|
||||
assert VALID_RUNTIME_MODES == ("light", "advanced", "pro")
|
||||
assert VALID_RUNTIME_MODES == ("light", "advanced", "pro", "cyber_pro")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["light", "advanced", "pro"])
|
||||
|
|
@ -1064,6 +1064,124 @@ def test_advanced_mode_blocks_runshell_protected_python_writer(tmp_path, monkeyp
|
|||
assert "BIBLE.md" in result
|
||||
|
||||
|
||||
def test_pro_mode_allows_runshell_protected_writer_with_core_notice(tmp_path, monkeypatch):
|
||||
"""Pro shell writes share the editor's protected-path allowance and notice."""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "pro")
|
||||
reg = _registry(tmp_path)
|
||||
result = reg.execute(
|
||||
"run_command",
|
||||
{"cmd": "python -c \"from pathlib import Path; Path('BIBLE.md').write_text('x')\""},
|
||||
)
|
||||
assert "SAFETY_VIOLATION" not in result
|
||||
assert "CORE_PATCH_NOTICE" in result
|
||||
assert (tmp_path / "BIBLE.md").read_text(encoding="utf-8") == "x"
|
||||
|
||||
|
||||
def test_pro_mode_keeps_bible_delete_blocked_but_allows_ordinary_rm(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "pro")
|
||||
(tmp_path / "BIBLE.md").write_text("constitution\n", encoding="utf-8")
|
||||
(tmp_path / "scratch.txt").write_text("scratch\n", encoding="utf-8")
|
||||
reg = _registry(tmp_path)
|
||||
bible_result = reg.execute("run_command", {"cmd": "rm BIBLE.md"})
|
||||
assert "BIBLE_DELETE_BLOCKED" in bible_result
|
||||
assert (tmp_path / "BIBLE.md").exists()
|
||||
rename_result = reg.execute("run_command", {"cmd": "git mv BIBLE.md BIBLE.old"})
|
||||
assert "BIBLE_DELETE_BLOCKED" in rename_result
|
||||
assert (tmp_path / "BIBLE.md").exists()
|
||||
python_result = reg.execute(
|
||||
"run_command", {"cmd": "python3 -c \"import os; os.remove('BIBLE.md')\""},
|
||||
)
|
||||
assert "BIBLE_DELETE_BLOCKED" in python_result
|
||||
subprocess_result = reg.execute(
|
||||
"run_command",
|
||||
{"cmd": "python3 -c \"import subprocess; subprocess.run(['rm','BIBLE.md'])\""},
|
||||
)
|
||||
assert "BIBLE_DELETE_BLOCKED" in subprocess_result
|
||||
update_index_result = reg.execute(
|
||||
"run_command", {"cmd": "git update-index --force-remove BIBLE.md"},
|
||||
)
|
||||
assert "BIBLE" in update_index_result
|
||||
identity = tmp_path / "memory" / "identity.md"
|
||||
identity.parent.mkdir()
|
||||
identity.write_text("identity\n", encoding="utf-8")
|
||||
identity_result = reg.execute("run_command", {"cmd": "rm memory/identity.md"})
|
||||
assert "IDENTITY_DELETE_BLOCKED" in identity_result
|
||||
assert identity.exists()
|
||||
identity_python = reg.execute(
|
||||
"run_command", {"cmd": "python3 -c \"import os; os.remove('memory/identity.md')\""},
|
||||
)
|
||||
assert "IDENTITY_DELETE_BLOCKED" in identity_python
|
||||
|
||||
|
||||
def test_cyber_pro_blocks_runtime_identity_delete_with_repo_data_split(tmp_path, monkeypatch):
|
||||
"""The production-shaped data/memory identity path stays present in Cyber."""
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "pro")
|
||||
repo = tmp_path / "repo"
|
||||
data = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
(data / "memory").mkdir(parents=True)
|
||||
identity = data / "memory" / "identity.md"
|
||||
identity.write_text("identity\n", encoding="utf-8")
|
||||
(data / "memory" / "scratch.txt").write_text("scratch\n", encoding="utf-8")
|
||||
from ouroboros.runtime_mode_policy import protected_bible_history_delete_reason
|
||||
|
||||
result = protected_bible_history_delete_reason(
|
||||
"rm memory/identity.md", protect_bible=False,
|
||||
identity_path=identity, cwd=data,
|
||||
)
|
||||
assert "IDENTITY_DELETE_BLOCKED" in result
|
||||
assert identity.exists()
|
||||
identity_shell = protected_bible_history_delete_reason(
|
||||
"python3 -c \"import subprocess; subprocess.run('rm memory/identity.md', shell=True)\"",
|
||||
protect_bible=False, identity_path=identity, cwd=data,
|
||||
)
|
||||
assert "IDENTITY_DELETE_BLOCKED" in identity_shell
|
||||
wrapped = protected_bible_history_delete_reason(
|
||||
["sh", "-c", "rm memory/identity.md"], protect_bible=False,
|
||||
identity_path=identity, cwd=data,
|
||||
)
|
||||
assert "IDENTITY_DELETE_BLOCKED" in wrapped
|
||||
assert identity.exists()
|
||||
scratch = protected_bible_history_delete_reason(
|
||||
"rm memory/scratch.txt", protect_bible=False,
|
||||
identity_path=identity, cwd=data,
|
||||
)
|
||||
assert scratch == ""
|
||||
|
||||
|
||||
def test_rank_aware_github_policy_keeps_ordinary_setup_blocked():
|
||||
from ouroboros.git_shell_policy import gh_shell_block_reason
|
||||
from ouroboros.runtime_mode_policy import runtime_mode_at_least, runtime_mode_rank
|
||||
|
||||
assert runtime_mode_rank("pro") >= runtime_mode_rank("advanced")
|
||||
assert runtime_mode_at_least("pro", "pro")
|
||||
assert gh_shell_block_reason("gh auth login", runtime_mode="pro")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"git rebase HEAD~1",
|
||||
"git update-ref refs/heads/feature HEAD",
|
||||
"git filter-repo --path other.txt --invert-paths",
|
||||
])
|
||||
def test_bible_history_predicate_allows_unrelated_git_history_operations(cmd):
|
||||
from ouroboros.runtime_mode_policy import protected_bible_history_delete_reason
|
||||
|
||||
assert protected_bible_history_delete_reason(cmd) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"git rm BIBLE.md",
|
||||
"git mv BIBLE.md BIBLE.old",
|
||||
"git checkout -- BIBLE.md",
|
||||
"git update-index --remove BIBLE.md",
|
||||
"git filter-repo --path BIBLE.md --invert-paths",
|
||||
])
|
||||
def test_bible_history_predicate_blocks_explicit_bible_targets(cmd):
|
||||
from ouroboros.runtime_mode_policy import protected_bible_history_delete_reason
|
||||
|
||||
assert "BIBLE" in protected_bible_history_delete_reason(cmd)
|
||||
|
||||
|
||||
def test_advanced_mode_blocks_runshell_protected_backslash_path(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
reg = _registry(tmp_path)
|
||||
|
|
|
|||
|
|
@ -241,6 +241,18 @@ def test_read_file_user_files_masks_secret_bytes_with_disclosure(user_files_ctx)
|
|||
assert not out.startswith("⚠️") # the read itself succeeds
|
||||
|
||||
|
||||
def test_cyber_owner_mode_can_read_literal_owner_file_bytes(user_files_ctx, monkeypatch):
|
||||
"""The explicit high-power owner mode may use a supplied key literally."""
|
||||
from ouroboros.tools import core_file_tools
|
||||
|
||||
ctx, home = user_files_ctx
|
||||
(home / "keys.txt").write_text(f"OPENROUTER_API_KEY={OPENROUTER_KEY}\n", encoding="utf-8")
|
||||
monkeypatch.setattr(core_file_tools, "_raw_owner_secret_access_allowed", lambda _ctx: True)
|
||||
out = _read_file(ctx, "keys.txt", root="user_files")
|
||||
assert OPENROUTER_KEY in out
|
||||
assert "SECRET_BYTES_MASKED" not in out
|
||||
|
||||
|
||||
def test_read_file_user_files_plain_file_has_no_masking_note(user_files_ctx):
|
||||
ctx, home = user_files_ctx
|
||||
(home / "notes.txt").write_text("just prose, nothing secret\n", encoding="utf-8")
|
||||
|
|
@ -335,6 +347,19 @@ def test_search_user_files_masks_secret_bytes_on_both_egresses(user_files_ctx, m
|
|||
assert "SECRET_BYTES_MASKED" in out_fb
|
||||
|
||||
|
||||
def test_cyber_owner_mode_can_search_literal_owner_file_bytes(user_files_ctx, monkeypatch):
|
||||
from ouroboros.tools import core as core_tools
|
||||
from ouroboros.tools import core_file_tools
|
||||
|
||||
ctx, home = user_files_ctx
|
||||
(home / "keys.txt").write_text(f"OPENROUTER_API_KEY={OPENROUTER_KEY}\n", encoding="utf-8")
|
||||
monkeypatch.setattr(core_file_tools, "_raw_owner_secret_access_allowed", lambda _ctx: True)
|
||||
monkeypatch.setattr(core_tools, "_raw_owner_secret_access_allowed", lambda _ctx: True)
|
||||
out = core_tools._code_search(ctx, "OPENROUTER", root="user_files")
|
||||
assert OPENROUTER_KEY in out
|
||||
assert "SECRET_BYTES_MASKED" not in out
|
||||
|
||||
|
||||
def test_search_non_user_files_root_is_not_masked(user_files_ctx):
|
||||
from ouroboros.tools.core import _code_search as _search_code
|
||||
|
||||
|
|
|
|||
55
tests/test_settings_policy_browser.py
Normal file
55
tests/test_settings_policy_browser.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Real Settings save/reload proof for the three independent policy controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_ui_smoke_playwright import direct_server_with_data # noqa: F401
|
||||
|
||||
|
||||
@pytest.mark.ui_browser
|
||||
def test_access_and_review_round_trip_without_losing_restart_truth(direct_server_with_data): # noqa: F811
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
fixture = direct_server_with_data
|
||||
evidence = pathlib.Path(os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(fixture["data_dir"].parent)))
|
||||
evidence.mkdir(parents=True, exist_ok=True)
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
try:
|
||||
def open_behavior():
|
||||
page.goto(fixture["url"], wait_until="domcontentloaded")
|
||||
page.locator('[data-nav-page="settings"]').click()
|
||||
page.locator('[data-settings-tab="behavior"]').click()
|
||||
page.wait_for_function("() => document.querySelector('#btn-save-settings')?.disabled === false")
|
||||
|
||||
open_behavior()
|
||||
page.locator('[data-runtime-mode-group] [data-effort-value="cyber_pro"]').click()
|
||||
page.locator('[data-enforcement-group] [data-effort-value="blocking"]').click()
|
||||
assert page.locator('#s-runtime-mode').input_value() == "cyber_pro"
|
||||
assert page.locator('#s-review-enforcement').input_value() == "blocking"
|
||||
assert page.locator('[data-enforcement-group] [data-effort-value="blocking"]').is_enabled()
|
||||
page.locator('#btn-save-settings').click()
|
||||
page.locator('[data-confirm-ok]').click()
|
||||
page.wait_for_function("() => !document.querySelector('#btn-save-settings').disabled && (document.querySelector('#settings-status').textContent || '').includes('restart required')")
|
||||
stored = json.loads((fixture["data_dir"] / "settings.json").read_text())
|
||||
assert stored["OUROBOROS_RUNTIME_MODE"] == "cyber_pro"
|
||||
assert stored["OUROBOROS_REVIEW_ENFORCEMENT"] == "blocking"
|
||||
|
||||
open_behavior()
|
||||
assert page.locator('#s-runtime-mode').input_value() == "cyber_pro"
|
||||
assert page.locator('#s-review-enforcement').input_value() == "blocking"
|
||||
access = page.locator('[data-policy-state="access"]')
|
||||
assert "Saved: Cyber Pro" in access.inner_text()
|
||||
assert "Current process: Light" in access.inner_text()
|
||||
assert "Next task: Cyber Pro" in access.inner_text()
|
||||
assert "Restart required" in access.inner_text()
|
||||
access.scroll_into_view_if_needed()
|
||||
page.screenshot(path=str(evidence / "settings-policy-reload-desktop.png"))
|
||||
finally:
|
||||
browser.close()
|
||||
92
tests/test_settings_policy_projection.py
Normal file
92
tests/test_settings_policy_projection.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Owner Settings projection keeps configured and effective policy truthful."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def test_setup_contract_exposes_cyber_pro_as_fourth_access_level():
|
||||
from ouroboros.settings_setup_contract import build_setup_contract
|
||||
|
||||
modes = build_setup_contract("web")["runtimeModes"]
|
||||
assert [mode["value"] for mode in modes] == [
|
||||
"light", "advanced", "pro", "cyber_pro"
|
||||
]
|
||||
cyber = modes[-1]
|
||||
assert cyber["label"] == "Cyber Pro"
|
||||
assert "Blocking" in cyber["copy"] and "Advisory" in cyber["copy"]
|
||||
|
||||
|
||||
def test_policy_projection_distinguishes_restart_and_next_task(monkeypatch):
|
||||
import ouroboros.config as config
|
||||
import ouroboros.gateway.settings as gateway
|
||||
|
||||
monkeypatch.setattr(config, "get_runtime_mode", lambda: "advanced")
|
||||
monkeypatch.setattr(config, "get_safety_mode", lambda: "full")
|
||||
monkeypatch.setattr(config, "normalize_runtime_mode", lambda value: str(value or "advanced"))
|
||||
monkeypatch.setattr(config, "normalize_safety_mode", lambda value: str(value or "full"))
|
||||
monkeypatch.setattr(gateway, "_has_started_agent_tasks", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"ouroboros.review_model_routes.get_review_enforcement",
|
||||
lambda: "advisory",
|
||||
)
|
||||
|
||||
state = gateway._build_policy_state({
|
||||
"OUROBOROS_RUNTIME_MODE": "cyber_pro",
|
||||
"OUROBOROS_SAFETY_MODE": "light",
|
||||
"OUROBOROS_REVIEW_ENFORCEMENT": "blocking",
|
||||
})
|
||||
|
||||
assert state["access"] == {
|
||||
"configured": "cyber_pro",
|
||||
"effective": "advanced",
|
||||
"current_process": "advanced",
|
||||
"next_task": "cyber_pro",
|
||||
"restart_required": True,
|
||||
"applies": "restart",
|
||||
}
|
||||
assert state["supervisor"]["current_process"] == "full"
|
||||
assert state["supervisor"]["next_task"] == "light"
|
||||
assert state["supervisor"]["pending"] is True
|
||||
assert state["supervisor"]["active_task_snapshot"] is True
|
||||
assert state["supervisor"]["applies"] == "next_task"
|
||||
assert state["review"]["current_process"] == "advisory"
|
||||
assert state["review"]["next_task"] == "blocking"
|
||||
assert state["review"]["pending"] is True
|
||||
assert state["review"]["active_task_snapshot"] is True
|
||||
assert state["review"]["applies"] == "next_task"
|
||||
assert state["running_task_snapshot"] is True
|
||||
|
||||
|
||||
def test_settings_get_exposes_policy_state_in_existing_meta(monkeypatch):
|
||||
import ouroboros.gateway.settings as gateway
|
||||
|
||||
settings = {
|
||||
"OUROBOROS_RUNTIME_MODE": "cyber_pro",
|
||||
"OUROBOROS_SAFETY_MODE": "full",
|
||||
"OUROBOROS_REVIEW_ENFORCEMENT": "advisory",
|
||||
}
|
||||
monkeypatch.setattr(gateway, "load_settings", lambda: dict(settings))
|
||||
monkeypatch.setattr(gateway, "apply_runtime_provider_defaults", lambda value: (value, False, []))
|
||||
monkeypatch.setattr(gateway, "_build_network_meta", lambda *_args: {})
|
||||
monkeypatch.setattr(gateway, "_port_file", lambda _request: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "_default_port", lambda _request: 8765)
|
||||
monkeypatch.setattr(gateway, "_has_started_agent_tasks", lambda: False)
|
||||
monkeypatch.setattr(gateway, "_build_policy_state", lambda _value: {
|
||||
"access": {"configured": "cyber_pro", "effective": "advanced", "current_process": "advanced", "next_task": "cyber_pro", "restart_required": True, "applies": "restart"},
|
||||
"supervisor": {"configured": "full", "effective": "full", "current_process": "full", "next_task": "full", "pending": False, "applies": "next_task", "active_task_snapshot": False},
|
||||
"review": {"configured": "advisory", "effective": "advisory", "current_process": "advisory", "next_task": "advisory", "pending": False, "applies": "next_task", "active_task_snapshot": False},
|
||||
"running_task_snapshot": False,
|
||||
})
|
||||
scope = {
|
||||
"type": "http", "method": "GET", "path": "/api/settings",
|
||||
"headers": [], "query_string": b"", "client": ("test", 1),
|
||||
"app": SimpleNamespace(state=SimpleNamespace()),
|
||||
}
|
||||
response = asyncio.run(gateway.api_settings_get(Request(scope)))
|
||||
payload = json.loads(response.body)
|
||||
assert payload["_meta"]["policy_state"]["access"]["restart_required"] is True
|
||||
|
|
@ -181,6 +181,34 @@ def test_prose_words_are_not_write_shapes_for_interpreters():
|
|||
assert interpreter_write_shape(["python3", "-c", "f.truncate(0)"]) is True
|
||||
|
||||
|
||||
def test_python_inline_strings_and_comments_do_not_create_write_shape():
|
||||
"""Writer vocabulary is structural: API names in output text/comments are
|
||||
not filesystem channels, while an actual redirect outside the body remains
|
||||
visible to the shell lane."""
|
||||
assert interpreter_write_shape(
|
||||
["python3", "-c", "print('BIBLE.md write_text'); # os.remove('/tmp/x')"]
|
||||
) is False
|
||||
assert interpreter_write_shape(
|
||||
"sh -c \"python3 -c 'print(\\\"BIBLE.md write_text\\\")'\""
|
||||
) is False
|
||||
assert interpreter_write_shape(
|
||||
"python3 -c \"print('BIBLE.md write_text')\" > report.txt"
|
||||
) is True
|
||||
|
||||
|
||||
def test_python_collection_constructor_remove_is_not_a_path_write():
|
||||
"""A list/tuple/set/dict constructor produces a collection receiver; an item
|
||||
named like a protected file is not a filesystem target."""
|
||||
from ouroboros.tools.shell_guards import writer_target_rows
|
||||
|
||||
command = [
|
||||
"python3", "-c",
|
||||
"xs = list(('BIBLE.md',)); xs.remove('BIBLE.md'); print(xs)",
|
||||
]
|
||||
assert interpreter_write_shape(command) is False
|
||||
assert writer_target_rows(command)[0][1] == []
|
||||
|
||||
|
||||
# --- guard layer: workspace lanes ------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,15 @@
|
|||
* @property {string[]=} custom_secret_keys
|
||||
* @property {Object=} setup_contract
|
||||
* @property {AvailableSubagentsSettingsMeta=} available_subagents
|
||||
* @property {SettingsPolicyState=} policy_state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SettingsPolicyState
|
||||
* @property {{configured:string,effective:string,current_process:string,next_task:string,restart_required:boolean,applies:string}} access
|
||||
* @property {{configured:string,effective:string,current_process:string,next_task:string,pending:boolean,applies:string,active_task_snapshot:boolean}} supervisor
|
||||
* @property {{configured:string,effective:string,current_process:string,next_task:string,pending:boolean,applies:string,active_task_snapshot:boolean}} review
|
||||
* @property {boolean} running_task_snapshot
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ import { accountRowFacts } from './harness_accounts.js';
|
|||
const MODEL_SLOTS = SETUP_CONTRACT.modelSlots || [];
|
||||
const REVIEW_MODES = SETUP_CONTRACT.reviewModes || [];
|
||||
const RUNTIME_MODES = SETUP_CONTRACT.runtimeModes || [];
|
||||
// The setup contract owns the access ladder. Pick the matching layout
|
||||
// for its current number of choices so adding Cyber Pro does not leave
|
||||
// the fourth card stranded under a three-column presentation.
|
||||
const RUNTIME_MODE_GRID_CLASS = RUNTIME_MODES.length > 3 ? 'four' : 'three';
|
||||
const LOCAL_ROUTING_MODES = SETUP_CONTRACT.localRoutingModes || [];
|
||||
const BUDGET_FIELDS = SETUP_CONTRACT.budgetFields || [];
|
||||
const LOCAL_FIELDS = [
|
||||
|
|
@ -826,9 +830,9 @@ import { accountRowFacts } from './harness_accounts.js';
|
|||
`).join('')}
|
||||
</div>
|
||||
<div class="panel-card runtime-mode-card">
|
||||
<h3>Runtime mode</h3>
|
||||
<h3>Access level</h3>
|
||||
<p class="field-note">${escapeHtml(runtimeModeCopy)}</p>
|
||||
<div class="wizard-choice-grid three">
|
||||
<div class="wizard-choice-grid ${RUNTIME_MODE_GRID_CLASS}">
|
||||
${RUNTIME_MODES.map((mode) => `
|
||||
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${runtimeMode === mode.value ? 'active' : ''}" data-runtime-mode="${escapeHtml(mode.value)}">
|
||||
<span class="tone">${escapeHtml(mode.tone)}</span>
|
||||
|
|
|
|||
|
|
@ -559,6 +559,15 @@ function planWaveDetail(wave) {
|
|||
}
|
||||
if (wave.findings_texts_truncated) lines.push('Some finding texts were truncated at capture.');
|
||||
if (wave.spec_body_truncated) lines.push('Spec body was truncated at capture.');
|
||||
const author = wave.author_disposition;
|
||||
if (author && typeof author === 'object' && text(author.disposition)) {
|
||||
lines.push(
|
||||
`Author finish: ${text(author.disposition)}${text(author.reviewer_signal) ? ` · reviewer signal=${text(author.reviewer_signal)}` : ''}`
|
||||
+ `${text(author.rationale) ? ` · ${text(author.rationale)}` : ''}`
|
||||
+ `${text(author.subject_hash) ? ` · reviewed_content_hash=${text(author.subject_hash)}` : ''}`
|
||||
+ `${text(author.source) ? ` · source=${text(author.source)}` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -796,7 +805,11 @@ export function taskAcceptanceGroupFromTaskDetail(detail, ownerTaskId = '') {
|
|||
execution: null,
|
||||
detailRef: { surface: 'task_acceptance', url: panel.applied_source_status === 'available'
|
||||
? taskSourceDownloadUrl(owner, panel.applied_source_ref) : '' },
|
||||
detailText: `${formatReviewProjection({ panels: [panel] })}\nCost unavailable`.trim(),
|
||||
detailText: `${formatReviewProjection({ panels: [panel] })}
|
||||
${panel.author_disposition && typeof panel.author_disposition === 'object' && text(panel.author_disposition.disposition)
|
||||
? `Author finish: ${text(panel.author_disposition.disposition)}${text(panel.author_disposition.reviewer_signal) ? ` · reviewer signal=${text(panel.author_disposition.reviewer_signal)}` : ''}${text(panel.author_disposition.rationale) ? ` · ${text(panel.author_disposition.rationale)}` : ''}${text(panel.author_disposition.subject_hash) ? ` · reviewed_content_hash=${text(panel.author_disposition.subject_hash)}` : ''}${text(panel.author_disposition.source) ? ` · source=${text(panel.author_disposition.source)}` : ''}`
|
||||
: ''}
|
||||
Cost unavailable`.trim(),
|
||||
};
|
||||
});
|
||||
const latest = attempts.at(-1);
|
||||
|
|
|
|||
|
|
@ -113,6 +113,32 @@ function setButtonBusy(button, busy) {
|
|||
else button.removeAttribute('aria-busy');
|
||||
}
|
||||
|
||||
function policyValueLabel(value) {
|
||||
const labels = {
|
||||
light: 'Light', advanced: 'Advanced', pro: 'Pro', cyber_pro: 'Cyber Pro',
|
||||
full: 'Full', off: 'Off', advisory: 'Advisory', blocking: 'Blocking',
|
||||
};
|
||||
return labels[String(value || '').trim().toLowerCase()] || String(value || 'Unknown');
|
||||
}
|
||||
|
||||
function syncPolicyState(root, meta) {
|
||||
const state = meta?.policy_state;
|
||||
if (!state) return;
|
||||
const render = (key, text) => {
|
||||
const node = root?.querySelector(`[data-policy-state="${key}"]`);
|
||||
if (node) node.textContent = text;
|
||||
};
|
||||
const access = state.access || {};
|
||||
render('access', access.restart_required
|
||||
? `Saved: ${policyValueLabel(access.configured)} · Current process: ${policyValueLabel(access.current_process || access.effective)} · Next task: ${policyValueLabel(access.next_task || access.configured)} · Restart required`
|
||||
: `Current process: ${policyValueLabel(access.current_process || access.effective)} · Next task: ${policyValueLabel(access.next_task || access.configured)}`);
|
||||
const suffix = (item) => item.active_task_snapshot
|
||||
? `Saved: ${policyValueLabel(item.configured)} · Current process: ${policyValueLabel(item.current_process || item.effective)} · Next task: ${policyValueLabel(item.next_task || item.configured)} · Current task keeps its start snapshot`
|
||||
: `Current process: ${policyValueLabel(item.current_process || item.effective)} · Next task: ${policyValueLabel(item.next_task || item.configured)}`;
|
||||
render('supervisor', suffix(state.supervisor || {}));
|
||||
render('review', suffix(state.review || {}));
|
||||
}
|
||||
|
||||
function readInt(id, fallback) {
|
||||
const value = parseInt(byId(id).value, 10);
|
||||
return Number.isNaN(value) ? fallback : value;
|
||||
|
|
@ -606,6 +632,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
resetSecretClearFlags(page);
|
||||
syncEffortSegments(page);
|
||||
syncRuntimeModeBridgeState();
|
||||
syncPolicyState(page, s?._meta);
|
||||
syncPostTaskEvolutionUi();
|
||||
refreshSafetySkipCounter(); // fire-and-forget; fills the 24h audited-skip note
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ const EFFORT_FIELDS = [
|
|||
['s-effort-consciousness', 'Consciousness', 'high'],
|
||||
];
|
||||
|
||||
// Runtime mode is one axis of the owner policy contract. Keep the Settings
|
||||
// presentation in the same vocabulary as the onboarding setup contract; the
|
||||
// saved value is still handled by settings.js and the owner endpoint.
|
||||
const RUNTIME_MODE_OPTIONS = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'advanced', label: 'Advanced' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
{ value: 'cyber_pro', label: 'Cyber Pro' },
|
||||
];
|
||||
|
||||
function providerCard({ id, title, icon, hint, body, open = false }) {
|
||||
return `
|
||||
<details class="settings-provider-card" data-provider-card="${id}" ${open ? 'open' : ''}>
|
||||
|
|
@ -405,6 +415,7 @@ export function renderSettingsPage() {
|
|||
{ value: 'blocking', label: 'Blocking' },
|
||||
],
|
||||
})}
|
||||
<div class="settings-inline-note" data-policy-state="review" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -542,6 +553,7 @@ export function renderSettingsPage() {
|
|||
{ value: 'off', label: 'Off' },
|
||||
],
|
||||
})}
|
||||
<div class="settings-inline-note" data-policy-state="supervisor" role="status" aria-live="polite"></div>
|
||||
<div id="s-safety-skip-counter" class="settings-section-copy"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,28 +583,26 @@ export function renderSettingsPage() {
|
|||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Runtime Mode</h3>
|
||||
<h3>Access</h3>
|
||||
<div class="settings-section-copy">
|
||||
Separate axis from Review Enforcement. Controls how far Ouroboros is allowed to self-modify.
|
||||
<code>Light</code> blocks repo self-modification but allows reviewed + enabled skills to run.
|
||||
<code>Advanced</code> is the default — self-modify the evolutionary layer; protected core/contract/release files stay guarded by the shared runtime-mode policy.
|
||||
<code>Pro</code> can edit protected core/contract/release surfaces, but commits still go through the normal triad + scope review gate; Advanced remains limited to the evolutionary layer.
|
||||
<code>Cyber Pro</code> grants the full host and configuration authority, including credentials, policy settings, and protected rewrites. Review Enforcement remains independent, so <code>Blocking</code> stays available in Cyber Pro.
|
||||
<br><strong>Human controlled:</strong> desktop builds ask the launcher for native confirmation before saving a mode change.
|
||||
Web/Docker sessions save mode changes through the owner endpoint; the new mode takes effect after restart.
|
||||
</div>
|
||||
<div class="settings-effort-card">
|
||||
<label>Runtime Mode</label>
|
||||
<label>Access level</label>
|
||||
<input id="s-runtime-mode" type="hidden" value="advanced">
|
||||
${renderSegmentedField({
|
||||
target: 's-runtime-mode',
|
||||
modifier: 'data-runtime-mode-group',
|
||||
title: 'Runtime mode changes require native launcher confirmation and restart.',
|
||||
options: [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'advanced', label: 'Advanced' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
],
|
||||
options: RUNTIME_MODE_OPTIONS,
|
||||
})}
|
||||
<div class="settings-inline-note" data-policy-state="access" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -519,10 +519,15 @@ textarea:focus {
|
|||
}
|
||||
|
||||
.wizard-choice-grid.three {
|
||||
/* Runtime-mode picker has three choices, not the default two. */
|
||||
/* Runtime-mode picker for contracts with three choices. */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wizard-choice-grid.four {
|
||||
/* Cyber Pro adds a fourth access level to the same independent picker. */
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wizard-choice {
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
|
|
|
|||
7
web/tests/fixtures/onboarding_bootstrap.json
vendored
7
web/tests/fixtures/onboarding_bootstrap.json
vendored
|
|
@ -299,6 +299,13 @@
|
|||
"label": "Pro",
|
||||
"tone": "Power",
|
||||
"value": "pro"
|
||||
},
|
||||
{
|
||||
"className": "cyber-pro",
|
||||
"copy": "Full host and configuration authority, including credentials, policy settings, and protected rewrites. Blocking or Advisory review remains your separate choice.",
|
||||
"label": "Cyber Pro",
|
||||
"tone": "Maximum power",
|
||||
"value": "cyber_pro"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
|
|
|
|||
|
|
@ -144,6 +144,20 @@ test(`importing the onboarding wizard renders the '${step}' step without throwin
|
|||
});
|
||||
}
|
||||
|
||||
test('the setup contract renders Cyber Pro beside the independent Blocking choice', async () => {
|
||||
const reviewIndex = BOOTSTRAP.stepOrder.indexOf('review_mode');
|
||||
const bootstrap = {
|
||||
...BOOTSTRAP,
|
||||
stepOrder: [...BOOTSTRAP.stepOrder.slice(reviewIndex), ...BOOTSTRAP.stepOrder.slice(0, reviewIndex)],
|
||||
};
|
||||
await withWizard(bootstrap, 'cyber-pro-grid', async ({ doc }) => {
|
||||
const html = doc.getElementById('root').innerHTML;
|
||||
assert.match(html, /wizard-choice-grid four/);
|
||||
assert.match(html, /data-runtime-mode="cyber_pro"[\s\S]*Cyber Pro/);
|
||||
assert.match(html, /data-review-mode="blocking"[\s\S]*Blocking/);
|
||||
});
|
||||
});
|
||||
|
||||
test('wizard renders and submits the edited owner draft on Finish', { timeout: 3000 }, async () => {
|
||||
// Keep the real contract and input handlers: only start at the model step,
|
||||
// after provider access, so this regression needs no subscription service.
|
||||
|
|
|
|||
|
|
@ -72,3 +72,16 @@ test('provider actions use the shared status-first action row contract', () => {
|
|||
assert.match(row, /aria-live="polite"/);
|
||||
}
|
||||
});
|
||||
|
||||
test('access, supervisor, and review remain independent owner controls', () => {
|
||||
const html = renderSettingsPage();
|
||||
assert.match(html, /id="s-runtime-mode"/);
|
||||
assert.match(html, /id="s-safety-mode"/);
|
||||
assert.match(html, /id="s-review-enforcement"/);
|
||||
assert.match(html, /data-policy-state="access"/);
|
||||
assert.match(html, /data-policy-state="supervisor"/);
|
||||
assert.match(html, /data-policy-state="review"/);
|
||||
assert.match(html, /data-effort-value="cyber_pro">Cyber Pro</);
|
||||
assert.match(html, /data-effort-value="blocking">Blocking</);
|
||||
assert.match(html, /Review Enforcement remains independent[\s\S]*Blocking.*available in Cyber Pro/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -963,6 +963,37 @@ test('task acceptance adapts only task_acceptance panels; advisory and commit st
|
|||
assert.deepEqual(reviewGroupsFromTaskDetail(detail).map((item) => item.surface), ['task_acceptance']);
|
||||
});
|
||||
|
||||
test('author finish is shown beside raw reviewer signal without becoming PASS', () => {
|
||||
const fingerprint = 'a'.repeat(64);
|
||||
const plan = planReviewGroupFromTaskDetail({
|
||||
task_id: 'root',
|
||||
plan_review_state: {
|
||||
current_attempt: { fingerprint, status: 'closed' },
|
||||
waves: [{
|
||||
request_fingerprint: fingerprint,
|
||||
aggregate: 'REVIEW_REQUIRED',
|
||||
closed: true,
|
||||
author_disposition: {
|
||||
disposition: 'partial',
|
||||
rationale: 'Fixed the defect and deferred cosmetic notes.',
|
||||
reviewer_signal: 'REVIEW_REQUIRED',
|
||||
subject_hash: fingerprint,
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
const attemptKey = `${plan.id}:${plan.attempts[0].id}`;
|
||||
const html = renderReviewsSection([plan], {
|
||||
sectionExpanded: true,
|
||||
expandedGroups: new Set([plan.id]),
|
||||
expandedAttempts: new Set([attemptKey]),
|
||||
});
|
||||
assert.match(html, /Author finish: partial/);
|
||||
assert.match(html, /reviewer signal=REVIEW_REQUIRED/);
|
||||
assert.match(html, /Fixed the defect and deferred cosmetic notes/);
|
||||
assert.match(html, /REVIEW_REQUIRED/);
|
||||
});
|
||||
|
||||
test('renderer is quiet, accessible and never invents review dollars', () => {
|
||||
const group = reviewGroupFromHistoryRow(groupedSkillRow());
|
||||
const html = renderReviewsSection([group], {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue