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:
Ouroboros 2026-09-10 05:23:37 +03:00
parent 8b6d5420b0
commit 7fffbdb250
75 changed files with 1921 additions and 162 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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) |

View file

@ -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).

View file

@ -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 "

View file

@ -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"])

View file

@ -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,

View file

@ -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 "

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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":

View file

@ -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":

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -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]:

View file

@ -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."

View file

@ -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."

View file

@ -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:

View file

@ -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"), (

View file

@ -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 ""),
)

View file

@ -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}

View file

@ -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": ""}

View file

@ -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.

View file

@ -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)

View file

@ -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",

View file

@ -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)

View file

@ -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)),

View file

@ -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."},

View file

@ -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"},
)

View file

@ -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;

View file

@ -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:

View file

@ -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."
)

View file

@ -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)

View file

@ -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

View file

@ -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"))

View file

@ -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")

View file

@ -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:

View file

@ -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(

View file

@ -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,

View file

@ -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)

View file

@ -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"],
},

View file

@ -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",

View file

@ -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:

View file

@ -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"

View file

@ -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.

View file

@ -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).

View file

@ -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
# --------------------------------------------------------------------------- #

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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

View 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 == {}

View file

@ -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"],

View file

@ -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)

View file

@ -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

View 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()

View 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

View file

@ -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 ------------------------------------------

View file

@ -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
*/
/**

View file

@ -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>

View file

@ -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);

View file

@ -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
}

View file

@ -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 &mdash; 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>

View file

@ -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;

View file

@ -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": [

View file

@ -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.

View file

@ -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/);
});

View file

@ -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], {