mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-05 00:29:46 +00:00
v5.21.0: harden edit targeting and widget host flows
This commit is contained in:
parent
7046b89c8f
commit
f705b373ba
30 changed files with 978 additions and 130 deletions
|
|
@ -6,7 +6,7 @@
|
|||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
|
||||
A self-modifying AI agent that writes its own code, rewrites its own mind, and evolves autonomously. Born February 16, 2026.
|
||||
|
||||
|
|
@ -419,11 +419,11 @@ not paraphrase it.
|
|||
|
||||
| Version | Date | Description |
|
||||
|---------|------|-------------|
|
||||
| 5.21.0 | 2026-05-14 | **feat(safety+widgets): harden unified edit targeting and repair Widgets host flows.** Makes `bucket`/`skill_name` short-form skill edits safe around explicit repo/data paths, adds a light-mode repo mutation tripwire for `run_shell` without auto-rollback, surfaces runtime mode in task context, restores widget span metadata, renders `subscription.render[]` progress/media children for Video/Anime Studio widgets, makes selected skill-review findings severity-driven, and pins budget hot-reload behavior with regression coverage. |
|
||||
| 5.20.1-rc.2 | 2026-05-13 | **rc(review): free advisory and surface skill auto-grant.** Surfaces reviewed-skill auto-grant requests/grants in outcomes and lifecycle payloads, shows granted items in headlines and chat review blocks, enables self-authored `auto_flow` wiring, and lets fresh advisory runs acknowledge open obligations/debts under advisory enforcement with a durable audit event. |
|
||||
| 5.20.1-rc.1 | 2026-05-13 | **rc(review): harden Claude advisory and skill-review observability.** Moves read-only Claude Code advisory onto the `ClaudeSDKClient` lifecycle, normalizes SDK token usage, routes advisory effort through the Scope Review setting, aligns skill advisory prompts with the Skill Review Checklist, persists skill advisory evidence/session metadata, and makes `bug_hunting` a critical skill-review blocker with concrete fix guidance. |
|
||||
| 5.20.0 | 2026-05-13 | **feat(mcp): add HTTP/SSE MCP client integration.** Adds a disabled-by-default base-runtime MCP client for trusted external tool servers, with Settings → Advanced configuration, masked token round-trips, hot-reloadable server refresh, provider-safe `mcp_<server>__<tool>` names, non-core tool opt-in, heal-mode blocking, per-call safety checks, URL/header validation, untrusted-description wrapping, and focused MCP API/registry/UI tests. Based on PR #51 by @kazzand, with MCP-only porting and hardening on the current architecture. |
|
||||
| 5.19.0-rc.3 | 2026-05-13 | **rc(tokens): raise default LLM output budgets.** Increases main-loop/chat defaults to 64K, VLM defaults to 32K, and summary/reflection/consolidation budgets to their new documented floors so long review/context artifacts are less likely to truncate task planning. |
|
||||
| 5.19.0-rc.2 | 2026-05-12 | **rc(build): harden macOS DMG creation after release CI.** Retries transient `hdiutil create` resource-busy failures with cleanup before regenerating the DMG, preserving the v5.19.0-rc.1 skill-review gate overhaul. |
|
||||
| 5.19.0-rc.1 | 2026-05-12 | **rc(skills): align skill review gates and collapse review evidence.** Renames skill verdicts to `clean`/`warnings`/`blockers`, separates verdicts from enforcement, unifies skill readiness and owner-message injection, syncs reviewed grants across content hashes, and collapses long skill-review system messages in Chat. |
|
||||
Older releases are preserved in Git tags and GitHub releases. The 5.2.0 through 5.18.0 rows and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
5.20.1-rc.2
|
||||
5.21.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Ouroboros v5.20.1-rc.2 — Architecture & Reference
|
||||
# Ouroboros v5.21.0 — Architecture & Reference
|
||||
|
||||
This document describes every component, page, button, API endpoint, and data flow.
|
||||
It is the single source of truth for how the system works. Keep it updated.
|
||||
|
|
@ -68,7 +68,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
├── skill_loader.py ← Skill discovery + durable skill state (v5.8.2: walks data/skills/{native,clawhub,ouroboroshub,external}/ + optional OUROBOROS_SKILLS_REPO_PATH; persists to data/state/skills/<name>/; tags each LoadedSkill with `source` and `.self_authored.json` provenance; v5.19 computes review verdicts live from stored findings)
|
||||
├── skill_readiness.py ← Central skill readiness helper: combines review gate, stale hash, enablement, and grants into a single finalization/execution verdict
|
||||
├── skill_dependencies.py ← Shared dependency-spec resolution for skill payloads across manifests, sidecars, and provenance
|
||||
├── skill_review_status.py ← Skill-review verdict aggregation SSOT (FAILs → clean/warnings/blockers/pending; enforcement maps verdicts to executable_review)
|
||||
├── skill_review_status.py ← Skill-review verdict aggregation SSOT (FAILs → clean/warnings/blockers/pending; hard trust-boundary items block on FAIL, bug_hunting + selected conditional safety items follow severity; enforcement maps verdicts to executable_review)
|
||||
├── skill_review.py ← Skill review pipeline: deterministic preflight + optional fail-open Claude Code advisory over the skill payload only (repo diff excluded, Skill Review Checklist output contract, scope-review effort, raw/session metadata persisted as advisory_result) followed by the tri-model executable trust gate against the Skill Review Checklist section of docs/CHECKLISTS.md plus minimal host skill/widget context (CREATING_SKILLS.md, PluginAPI contract, extension UI validator); supports rebuttal/history/convergence evidence
|
||||
├── extension_loader.py ← Phase 4 in-process loader for type: extension skills; discovers + imports plugin.py via importlib with a narrow PluginAPIImpl, tracks registrations per-skill for atomic unload
|
||||
├── extension_ui_validation.py ← Host-owned widget/settings render-schema validation shared by extension loader and skill preflight
|
||||
|
|
@ -366,13 +366,13 @@ The web UI is a single-page app (`web/index.html` + `web/style.css` + ES modules
|
|||
- `masonry.js` — shared JS masonry layout helper for uneven-height widget cards; computes packed positions and emits scoped CSS rules rather than inline element styles.
|
||||
- `toast.js` — fixed-position notification helper used for transient skill/marketplace/status feedback without shifting page layout.
|
||||
- `skills.js` — Skills page (discover + enable/disable + review trigger + Repair task affordance for non-native failing skills + key-grant state + live-vs-catalog extension status; reads `/api/state` + `/api/extensions`, writes through `/api/skills/<name>/toggle` + `/api/skills/<name>/review`, sends Repair prompts through `/api/command`, and requests key grants through the desktop launcher bridge)
|
||||
- `widgets.js` — Widgets page for reviewed extension UI surfaces declared through `register_ui_tab`; hosts legacy `inline_card`/`iframe` plus declarative v1 widgets (forms/actions, async job forms/actions, markdown, code, JSON, key/value, tables, tabs, charts, stream, progress, `subscription` WS updates, poll with `auto_start`, files, galleries, image/audio/video media, **map/calendar/kanban (v5.7.0)**) and tears down widget timers/listeners/streams on remount while resuming async jobs by `job_id`. **v5.7.0** also adds `kind: "module"` widgets: the host fetches reviewed `widget.js` through `/api/extensions/<skill>/module/<entry>`, embeds it into a sandboxed `<iframe srcdoc sandbox="allow-scripts">` with no `allow-same-origin`, and injects a parent-mediated `fetch` bridge restricted to `/api/extensions/<skill>/...`.
|
||||
- `widgets.js` — Widgets page for reviewed extension UI surfaces declared through `register_ui_tab`; hosts legacy `inline_card`/`iframe` plus declarative v1 widgets (forms/actions, async job forms/actions, markdown, code, JSON, key/value, tables, tabs, charts, stream, progress, `subscription` WS updates with optional passive `render[]` children, poll with `auto_start`, files, galleries, image/audio/video media, **map/calendar/kanban (v5.7.0)**) and tears down widget timers/listeners/streams on remount while resuming async jobs by `job_id`. Live UI tab snapshots may carry top-level `span` / `grid_span` metadata (promoted from `render.span` / `render.grid_span`) so wide widgets can occupy two masonry columns without changing the frozen `PluginAPI.register_ui_tab` signature. **v5.7.0** also adds `kind: "module"` widgets: the host fetches reviewed `widget.js` through `/api/extensions/<skill>/module/<entry>`, embeds it into a sandboxed `<iframe srcdoc sandbox="allow-scripts">` with no `allow-same-origin`, and injects a parent-mediated `fetch` bridge restricted to `/api/extensions/<skill>/...`.
|
||||
|
||||
(`about.js` was removed in v5.7.0 when About moved into Settings as a sub-tab.)
|
||||
|
||||
Navigation is a left sidebar with 6 pages (Chat, Files, Skills, Widgets, Dashboard, Settings). About lives as a sub-tab inside Settings (v5.7.0+) — there is no top-level About page; the desktop launcher's `#nav-version` span keeps a compact version label visible above the rail's footer. Dashboard is the operational hub for Logs, Evolution, Costs, and Updates; Settings holds Providers / Secrets / Models / Behavior / Advanced / About sub-tabs. The old Integrations tab stays absent: transport protocols such as A2A and Telegram live as reviewed skills, while the base-runtime MCP client is configured from Advanced because it changes the agent's external tool surface. The Dashboard nav button uses the Lucide `gauge` icon (a half-circle speedometer with needle) — the previous `layout-dashboard` glyph was visually indistinguishable from the `layout-grid` Widgets glyph at 20×20 px. On narrow viewports (`@media (max-width: 640px)`) `#nav-rail` collapses to a horizontal bottom bar — `position: fixed; bottom: 0; flex-direction: row; justify-content: safe center` with `padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px))` for the iOS home-indicator and `#content { padding-left: 0; padding-bottom: calc(62px + env(safe-area-inset-bottom, 0px)) }` to clear the bar. Mobile Settings keeps the horizontal pill strip (no drill-down accordion) — the active pill auto-scrolls into view via `scrollIntoView({ inline: 'center' })`. The Skills page manages external + bundled skill packages — review trigger, key grants, enable/disable, status badges, and live-vs-catalog extension state — and reads from `/api/state` + `/api/extensions`. Each skill card carries a kebab (⋮) menu in its header (right of the toggle) that opens as an anchored non-modal popover via `dialog.show()`; the menu hosts Re-review / Update / Uninstall actions. The Widgets page hosts reviewed extension UI declarations separately so useful widgets do not get buried in long skill lists; inline-card widgets now preserve their current state across SPA tab switches.
|
||||
|
||||
v5.7.2 normalizes page-level headers and top tab strips through `web/modules/page_header.js`: Settings, Dashboard, Skills, Widgets, Files, and Chat share the same title/action/tab structure and `app-page-*` / `app-tab-*` CSS rhythm. Chat keeps the `chat-page-header` overlay variant for scroll-under behavior; Settings/Dashboard/Skills tabs are all horizontal pill strips. v5.11.0 extends the same skill-first boundary to authoring: `runtime_mode=light` is a compatibility/self-modification guard, not a full shell sandbox. It blocks core/repo mutation tools, mutative git through `run_shell`, and simple writer commands with explicit repo-local targets, but scoped tools (`str_replace_editor`, `data_write`, `claude_code_edit`) may edit files under `data/skills/{external,clawhub,ouroboroshub}/<skill>/...` outside formal Repair tasks. Native payloads, provenance sidecars, and owner state stay protected. Formal `task_constraint.mode="skill_repair"` still exists as the stricter UI Repair lane with short payload-relative paths and a narrow allowlist.
|
||||
v5.7.2 normalizes page-level headers and top tab strips through `web/modules/page_header.js`: Settings, Dashboard, Skills, Widgets, Files, and Chat share the same title/action/tab structure and `app-page-*` / `app-tab-*` CSS rhythm. Chat keeps the `chat-page-header` overlay variant for scroll-under behavior; Settings/Dashboard/Skills tabs are all horizontal pill strips. v5.21.0 keeps the v5.11 skill-first boundary but hardens its target resolution: `runtime_mode=light` is a compatibility/self-modification guard, not a full shell sandbox. It blocks core/repo mutation tools, mutative git through `run_shell`, simple writer commands with explicit repo-local targets, and post-execution repo dirtiness detected after `run_shell` (`LIGHT_MODE_REPO_WRITE_BLOCKED`, no automatic rollback to avoid overwriting concurrent human edits). Scoped tools (`str_replace_editor`, `data_write`, `claude_code_edit`) may edit files under existing `data/skills/{external,clawhub,ouroboroshub}/<skill>/...` outside formal Repair tasks. Optional `bucket` + `skill_name` short-form args are only sugar for short payload-relative paths; explicit repo/data paths keep their own address space and ignore stale short-form args. Native payloads, provenance sidecars, and owner state stay protected. Formal `task_constraint.mode="skill_repair"` still exists as the stricter UI Repair lane with short payload-relative paths and a narrow allowlist.
|
||||
|
||||
v5.8.3-rc.1 splits web escaping by context in `web/modules/utils.js`. Use
|
||||
`escapeHtmlText()` for text-node HTML and markdown fallbacks, `escapeHtmlAttr()`
|
||||
|
|
@ -1703,7 +1703,7 @@ Runtime floors:
|
|||
| OUROBOROS_REVIEW_MODEL_TIMEOUT_SEC | 600 | Env-only override read directly by `ouroboros.tools.review`. Per-reviewer model call timeout for multi-model review; timed-out reviewers become ERROR actors and quorum still requires at least two parseable reviewers. |
|
||||
| OUROBOROS_REVIEW_ENFORCEMENT | advisory | Review enforcement: `blocking` blocks commit critical findings, fresh-advisory open obligations/debts, and skill `blockers`; `advisory` downgrades those to warnings by operator choice. Fresh advisory with open obligations/debts writes `advisory_obligations_acknowledged`; stale advisory still blocks. Skill `warnings` do not block execution in either mode. |
|
||||
| OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS | false | Desktop-owner confirmed setting. When enabled, any completed skill review verdict (`clean`, `warnings`, or `blockers`) grants only the manifest-declared settings keys and host permissions for that exact content hash so closed-loop skill development can run without repeated manual grants. Plain `/api/settings` POST drops this key; desktop uses the launcher confirmation bridge. |
|
||||
| OUROBOROS_RUNTIME_MODE | advanced | Three-layer refactor axis: `light`, `advanced`, or `pro`. Orthogonal to `OUROBOROS_REVIEW_ENFORCEMENT`. Clamped via `normalize_runtime_mode` on both save and read paths. `light` is a compatibility/self-modification guard: it blocks repo-mutation tools at the `ToolRegistry.execute` gate, mutative direct git through `run_shell`, and shallow argv writer commands with explicit repo-local targets, while leaving normal shell/Python/Node diagnostics usable. It also refuses runtime_mode self-elevation through the owner chokepoints (`save_settings`, `_data_write` settings.json block, `/api/settings` POST drop). Reviewed + enabled skills (script + extension) execute in light. `advanced` can evolve the application layer but blocks protected core/contract/release paths. `pro` may edit those protected surfaces directly, but committing them still requires the normal triad + scope review to pass. The runtime_mode value itself is owner-only — change it by editing `settings.json` directly while the agent is stopped, then restart. |
|
||||
| OUROBOROS_RUNTIME_MODE | advanced | Three-layer refactor axis: `light`, `advanced`, or `pro`. Orthogonal to `OUROBOROS_REVIEW_ENFORCEMENT`. Clamped via `normalize_runtime_mode` on both save and read paths. `light` is a compatibility/self-modification guard: it blocks repo-mutation tools at the `ToolRegistry.execute` gate, mutative direct git through `run_shell`, shallow argv writer commands with explicit repo-local targets, and post-execution repo dirtiness from `run_shell` (`LIGHT_MODE_REPO_WRITE_BLOCKED`, no automatic rollback). It also refuses runtime_mode self-elevation through the owner chokepoints (`save_settings`, `_data_write` settings.json block, `/api/settings` POST drop). Reviewed + enabled skills (script + extension) execute in light. `advanced` can evolve the application layer but blocks protected core/contract/release paths. `pro` may edit those protected surfaces directly, but committing them still requires the normal triad + scope review to pass. The runtime_mode value itself is owner-only — change it by editing `settings.json` directly while the agent is stopped, then restart. |
|
||||
| OUROBOROS_SKILLS_REPO_PATH | "" | Local checkout path for the external skills/extensions repo. Consumed by `ouroboros.skill_loader.discover_skills` (Phase 3); accepts absolute paths or `~`-prefixed paths; `get_skills_repo_path` expands `~` at read time. Ouroboros never clones/pulls this directory. |
|
||||
| MCP_ENABLED | false | Optional. Enables the base-runtime HTTP/SSE MCP tool client. |
|
||||
| MCP_SERVERS | [] | List of MCP server config dicts persisted in settings.json; not propagated through env. |
|
||||
|
|
@ -2633,4 +2633,4 @@ Bridge skills pass external-chat provenance through a generic `transport` object
|
|||
|
||||
`ouroboros/contracts/task_constraint.py` is an internal structured task envelope used by server, worker, and tool-registry code to carry skill-repair constraints. It is contract-tested by `tests/test_task_constraint_tools.py` and `tests/test_task_constraint_server_routing.py`, but it is not a public PluginAPI surface.
|
||||
|
||||
`ouroboros/contracts/skill_payload_policy.py` is an internal shared resolver for tool/runtime policy, not a public PluginAPI surface. It confines explicit `data/skills/{external,clawhub,ouroboroshub}/<skill>/...` payload edits, rejects mismatched `skill_repair` constraints, centralizes control-plane sidecar detection so registry, shell, and git tools do not copy/paste bucket logic, and exposes `synthesize_payload_constraint(bucket, skill_name)` so the `runtime_mode=light` short-form (optional `bucket` + `skill_name` args on `data_write` / `str_replace_editor` / `claude_code_edit`) reuses the `skill_repair`-mode resolution without introducing a new task-constraint mode.
|
||||
`ouroboros/contracts/skill_payload_policy.py` is an internal shared resolver for tool/runtime policy, not a public PluginAPI surface. It confines explicit `data/skills/{external,clawhub,ouroboroshub}/<skill>/...` payload edits, rejects mismatched `skill_repair` constraints, centralizes control-plane sidecar detection so registry, shell, and git tools do not copy/paste bucket logic, and exposes `decide_payload_short_form(...)` / `synthesize_payload_constraint(bucket, skill_name)` so optional `bucket` + `skill_name` args on `data_write` / `str_replace_editor` / `claude_code_edit` reuse the `skill_repair`-mode resolution without overriding explicit repo/data paths or introducing a new task-constraint mode.
|
||||
|
|
|
|||
|
|
@ -385,15 +385,15 @@ total). Each entry carries `item`, `verdict` (`PASS`/`FAIL`), `severity`
|
|||
| 4 | path_confinement | Do scripts stay inside the skill directory and the dedicated state dir (`~/Ouroboros/data/state/skills/<name>/`)? Absolute paths, `..` traversal, and writes to arbitrary user home subdirs are concrete FAIL. Reading from outside the skill dir is OK for read-only lookups (e.g. system info), write-path confinement is the strict rule. | critical |
|
||||
| 5 | env_allowlist | Is `env_from_settings` a short, justified list of settings keys? Core keys in `FORBIDDEN_SKILL_SETTINGS` (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_COMPATIBLE_API_KEY`, `CLOUDRU_FOUNDATION_MODELS_API_KEY`, `ANTHROPIC_API_KEY`, `TELEGRAM_BOT_TOKEN`, `GITHUB_TOKEN`, `OUROBOROS_NETWORK_PASSWORD`) may be declared only when the skill genuinely needs that provider/token for its stated purpose; runtime forwards them only after a fresh executable review and a content-bound desktop-launcher owner grant. v5.2.2 dual-track grants: both `type: script` skills (forwarded by `_scrub_env`) and `type: extension` skills (forwarded by `PluginAPIImpl.get_settings`) are eligible; `type: instruction` skills cannot receive core keys. Mark unjustified core-key requests or non-forbidden secrets unrelated to the purpose as FAIL. An empty list is the default and always fine. | critical |
|
||||
| 6 | timeout_and_output_discipline | Is `timeout_sec` reasonable for the stated workload (default 60, hard cap 300)? Do scripts print to stdout in chunks that the runtime can cap, rather than streaming unbounded output? Unbounded loops without a `break`/timeout path are a concrete FAIL. | advisory |
|
||||
| 7 | extension_namespace_discipline | `type: extension` only: does the extension register its tool/route/ws-handler/ui-tab under the namespace derived from its `name` (e.g. provider-safe tool/ws names like `ext_<len>_<token>_<surface>`, route `/api/extensions/<name>/…`)? Tool and WS short names must be alphanumeric/underscore and at most 24 characters. Namespace collisions with built-in surfaces are a concrete FAIL. If the extension uses `api.send_ws_message`, are emitted event names short/provider-safe and paired with reviewed host-owned widget `subscription` components rather than arbitrary same-origin JavaScript? If the extension declares streaming UI, is it a reviewed extension route consumed by a host-owned `stream` component? If the extension owns background resources (threads, sockets, EventSource clients, subprocesses), does it register cleanup with `api.on_unload(callback)`? If the extension declares a widget render block, is it one of the host-owned schemas (`iframe`, `inline_card`, or declarative v1: forms/actions, markdown/code, JSON/kv/table, tabs/chart, stream/subscription, progress/poll, file/gallery/media, **map/calendar/kanban (v5.7.0)**), with media sourced from extension routes or safe data URLs and no arbitrary same-origin JavaScript? For non-extension skills, verdict PASS with reason "Not applicable — type != extension." | critical |
|
||||
| 8 | widget_module_safety | **v5.7.0+. ``kind: "module"`` widgets only.** Does the extension-supplied ``widget.js`` avoid touching ``document.cookie``, ``localStorage``, ``sessionStorage``, ``window.parent`` data, or ``fetch``/``XMLHttpRequest`` URLs OUTSIDE ``/api/extensions/<skill>/``? The host fetches reviewed ``widget.js`` through ``GET /api/extensions/<skill>/module/<entry>``, embeds the source into a sandboxed ``<iframe srcdoc sandbox="allow-scripts">`` with no ``allow-same-origin``, and injects a parent-mediated ``fetch`` bridge that rejects paths outside the owning skill route prefix. Reviewers must still confirm at the source level that the script is NOT trying to escape the sandbox via arbitrary ``postMessage`` protocols, opaque-origin storage probes, or unauthorised cross-origin fetches. Acceptable interactions: ``fetch('/api/extensions/<skill>/...')`` (through the host bridge), ``window.OuroborosWidget.fetch('/api/extensions/<skill>/...')``, and host-supplied data attributes. Mark non-module widgets and non-extension skills PASS with reason "Not applicable". | critical (when kind=module) |
|
||||
| 7 | extension_namespace_discipline | `type: extension` only: does the extension register its tool/route/ws-handler/ui-tab under the namespace derived from its `name` (e.g. provider-safe tool/ws names like `ext_<len>_<token>_<surface>`, route `/api/extensions/<name>/…`)? Tool and WS short names must be alphanumeric/underscore and at most 24 characters. Namespace collisions with built-in surfaces are a concrete FAIL. If the extension uses `api.send_ws_message`, are emitted event names short/provider-safe and paired with reviewed host-owned widget `subscription` components rather than arbitrary same-origin JavaScript? If the extension declares streaming UI, is it a reviewed extension route consumed by a host-owned `stream` component? If the extension owns background resources (threads, sockets, EventSource clients, subprocesses), does it register cleanup with `api.on_unload(callback)`? If the extension declares a widget render block, is it one of the host-owned schemas (`iframe`, `inline_card`, or declarative v1: forms/actions, markdown/code, JSON/kv/table, tabs/chart, stream/subscription, progress/poll, file/gallery/media, **map/calendar/kanban (v5.7.0)**), with media sourced from extension routes or safe data URLs and no arbitrary same-origin JavaScript? For non-extension skills, verdict PASS with reason "Not applicable — type != extension." | severity-driven for applicable extensions |
|
||||
| 8 | widget_module_safety | **v5.7.0+. ``kind: "module"`` widgets only.** Does the extension-supplied ``widget.js`` avoid touching ``document.cookie``, ``localStorage``, ``sessionStorage``, ``window.parent`` data, or ``fetch``/``XMLHttpRequest`` URLs OUTSIDE ``/api/extensions/<skill>/``? The host fetches reviewed ``widget.js`` through ``GET /api/extensions/<skill>/module/<entry>``, embeds the source into a sandboxed ``<iframe srcdoc sandbox="allow-scripts">`` with no ``allow-same-origin``, and injects a parent-mediated ``fetch`` bridge that rejects paths outside the owning skill route prefix. Reviewers must still confirm at the source level that the script is NOT trying to escape the sandbox via arbitrary ``postMessage`` protocols, opaque-origin storage probes, or unauthorised cross-origin fetches. Acceptable interactions: ``fetch('/api/extensions/<skill>/...')`` (through the host bridge), ``window.OuroborosWidget.fetch('/api/extensions/<skill>/...')``, and host-supplied data attributes. Mark non-module widgets and non-extension skills PASS with reason "Not applicable". | severity-driven when kind=module |
|
||||
| 9 | inject_chat_minimization | Does any use of the `inject_chat` permission have a narrow, user-facing transport purpose? The Host Service enforces token auth, skill-source attribution, slash-command rejection, rate limits, and in-flight limits; reviewers must not claim reserved slash commands would execute when that host guard is present. A skill that accepts external inbound traffic must still show local defense-in-depth: reject owner-like slash-command-shaped input before injection and rate-limit or backpressure traffic before it piles up. Missing local defense-in-depth is a concrete FAIL for network transports; attempting to inject `/panic`, `/restart`, `/review`, `/evolve`, `/bg`, `/status`, or owner-impersonating instructions is always a concrete FAIL. Mark PASS with reason "Not applicable" when `inject_chat` is not declared. | critical |
|
||||
| 10 | event_subscription_minimization | Are `subscribe_event` and `subscribe_events` limited to the minimum host event topics required by the skill? `chat.outbound`, `chat.typing`, and `chat.photo` expose owner/agent conversation data and require explicit justification. Wildcards, undeclared topics, or forwarding subscribed chat content to unrelated external services are concrete FAILs. Mark PASS with reason "Not applicable" when `subscribe_event` is not declared. | critical |
|
||||
| 11 | companion_process_safety | For `companion_process` / `supervised_task` skills: is every command declared as an argument list (not shell string), using an allowlisted runtime, with no writes outside `skill_dir` / `state_dir`, no unbounded restart loop, and cleanup on unload/panic? Does the process avoid inheriting secrets except through reviewed `env_from_settings` grants? Mark PASS with reason "Not applicable" when no long-lived process/task is declared — a transient `subprocess.run`/`subprocess.Popen` invocation of a build tool like `ffmpeg`, `ImageMagick`, or `git` inside a normal request handler is NOT a long-lived companion process and does not trigger this item (its safety belongs under items 4 / 6 / 13). | critical |
|
||||
| 11 | companion_process_safety | For `companion_process` / `supervised_task` skills: is every command declared as an argument list (not shell string), using an allowlisted runtime, with no writes outside `skill_dir` / `state_dir`, no unbounded restart loop, and cleanup on unload/panic? Does the process avoid inheriting secrets except through reviewed `env_from_settings` grants? Mark PASS with reason "Not applicable" when no long-lived process/task is declared — a transient `subprocess.run`/`subprocess.Popen` invocation of a build tool like `ffmpeg`, `ImageMagick`, or `git` inside a normal request handler is NOT a long-lived companion process and does not trigger this item (its safety belongs under items 4 / 6 / 13). | severity-driven when applicable |
|
||||
| 12 | host_token_handling | If the skill calls the Host Service API, does it use the provided `SkillToken.use_in_request()` only at request construction sites, avoid logging/serializing tokens, and keep all host-service calls on the loopback endpoint? Printing, persisting, exfiltrating, or embedding the token into user-visible output is a concrete FAIL. Mark PASS with reason "Not applicable" when the skill does not access the Host Service API. | critical |
|
||||
| 13 | error_handling | Does the skill surface actionable errors instead of swallowing exceptions, returning success on partial failure, or leaving users to inspect raw logs manually? Are retry/backoff paths bounded and purpose-specific? | advisory |
|
||||
| 14 | integration_preflight | Does the skill include cheap local preflight checks for the APIs/files/runtimes it depends on before spending provider budget or starting long work? Missing preflight for fragile external integrations is an advisory FAIL. | advisory |
|
||||
| 15 | bug_hunting | Are there obvious runtime bugs in reviewed code: wrong filenames, mismatched manifest script names, missing imports, impossible arguments, JSON/schema mismatches, blocking calls in async handlers, or untested happy-path assumptions? For every FAIL, cite the concrete runtime bug and state how you propose to fix it (file/symbol/change), so the author can apply the correction instead of guessing. | critical |
|
||||
| 15 | bug_hunting | Are there obvious runtime bugs in reviewed code: wrong filenames, mismatched manifest script names, missing imports, impossible arguments, JSON/schema mismatches, blocking calls in async handlers, or untested happy-path assumptions? For every FAIL, cite the concrete runtime bug and state how you propose to fix it (file/symbol/change), so the author can apply the correction instead of guessing. Concrete likely runtime breakage should use `severity=critical`; latent issues, provider fragility, minor cleanup, or dead-code concerns should use `severity=advisory`. | severity-driven |
|
||||
| 16 | completion_notification | For long-running or user-visible work, does the skill emit or document a completion/failure notification path (for example a host event, `events.jsonl` append, or clear stdout marker consumed by Ouroboros)? Mark PASS with reason "Not applicable" for tiny synchronous utilities. | advisory |
|
||||
|
||||
### Severity rules
|
||||
|
|
@ -414,19 +414,24 @@ total). Each entry carries `item`, `verdict` (`PASS`/`FAIL`), `severity`
|
|||
- Review state stores findings and computes the verdict at load time. Agents
|
||||
and UI callers must use `review_gate.executable_review` / `executable_review`,
|
||||
not the raw status string, when deciding whether the skill is runnable.
|
||||
- Item 7 is conditionally critical: FAIL only when `type: extension`.
|
||||
- Item 8 (`widget_module_safety`) is critical for any `type: extension`
|
||||
if the reviewer returns FAIL. Reviewers MUST mark it PASS with reason
|
||||
"Not applicable" when the extension does not use a module widget. This
|
||||
runtime rule deliberately does not rely only on manifest `ui_tab`
|
||||
detection because extensions can register module widgets dynamically from
|
||||
`plugin.py` via `PluginAPI.register_ui_tab`.
|
||||
- Items 9–12 are critical only when their corresponding capability is
|
||||
- Hard trust-boundary items are blocker findings on any FAIL regardless of
|
||||
reviewer-supplied severity: `skill_preflight`, `manifest_schema`,
|
||||
`permissions_honesty`, `no_repo_mutation`, `path_confinement`,
|
||||
`env_allowlist`, `inject_chat_minimization`,
|
||||
`event_subscription_minimization`, and `host_token_handling`.
|
||||
- Items 7, 8, 11, and 15 are severity-driven. A `FAIL` with
|
||||
`severity=critical` produces `blockers`; a `FAIL` with
|
||||
`severity=advisory` produces `warnings`. Reviewers MUST reserve
|
||||
critical severity for concrete dangerous or runtime-breaking cases
|
||||
that meet the Critical threshold rule below.
|
||||
- Item 8 (`widget_module_safety`) applies to module widgets. Reviewers MUST
|
||||
mark it PASS with reason "Not applicable" when the extension does not
|
||||
use a module widget. This runtime rule deliberately does not rely only
|
||||
on manifest `ui_tab` detection because extensions can register module
|
||||
widgets dynamically from `plugin.py` via `PluginAPI.register_ui_tab`.
|
||||
- Items 9, 10, and 12 are critical only when their corresponding capability is
|
||||
declared or used. Reviewers MUST mark them PASS with reason "Not
|
||||
applicable" for skills outside that surface.
|
||||
- Item 15 (`bug_hunting`) is critical: concrete likely runtime breakage
|
||||
blocks execution until the skill payload is fixed or the finding is
|
||||
rebutted with evidence.
|
||||
|
||||
### Critical threshold rule (applies to ALL items)
|
||||
|
||||
|
|
|
|||
|
|
@ -467,7 +467,12 @@ For everything else, prefer the existing declarative components
|
|||
(form / action / poll / subscription / stream / table / chart /
|
||||
markdown / json / kv / status / tabs / progress / gallery /
|
||||
image / audio / video / file / map / calendar / kanban). They
|
||||
handle XSS / CSRF / lifecycle automatically.
|
||||
handle XSS / CSRF / lifecycle automatically. A top-level
|
||||
`subscription` may include `render: [...]` passive display children
|
||||
(`progress`, `gallery`, `key_value`, `markdown`, etc.) that render the
|
||||
latest WebSocket payload for that subscription. Do not put interactive
|
||||
children such as `form`, `action`, `poll`, `stream`, nested
|
||||
`subscription`, or `tabs` inside `subscription.render`.
|
||||
|
||||
### Widget composition rules
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ Before every commit, verify the following:
|
|||
- In repair mode, edit paths are payload-relative: `plugin.py` means the selected `data/skills/{external,clawhub,ouroboroshub}/<skill>/plugin.py`.
|
||||
- Use `str_replace_editor` for one exact replacement, `claude_code_edit` for coordinated multi-hunk edits, and `data_write` only for new files or intentional full rewrites.
|
||||
- Finish repair with `skill_preflight` and `review_skill`; grants and enablement stay owner-controlled.
|
||||
- Repair mode is a stricter UI lane, not the only path for skill authoring. In `runtime_mode=light`, ordinary chat tasks may edit explicit `data/skills/{external,clawhub,ouroboroshub}/<skill>/...` payload paths via `str_replace_editor`, `data_write`, or `claude_code_edit`. As a second short-form, those three tools also accept optional `bucket` + `skill_name` args; when both are supplied (and `bucket` is not `native`), a short relative `path`/`cwd` (e.g. `plugin.py`, `lib/utils.py`, or `.`) resolves under `data/skills/<bucket>/<skill_name>/` via `synthesize_payload_constraint` in `ouroboros.contracts.skill_payload_policy` — the same `skill_repair`-mode resolution is reused, no new task-constraint mode is introduced. Core/repo paths, `data/skills/native/*`, `data/state/skills/*`, marketplace/provenance sidecars, and direct `run_shell` writes to repo targets remain blocked.
|
||||
- Repair mode is a stricter UI lane, not the only path for skill authoring. In `runtime_mode=light`, ordinary chat tasks may edit explicit `data/skills/{external,clawhub,ouroboroshub}/<skill>/...` payload paths via `str_replace_editor`, `data_write`, or `claude_code_edit`. As a second short-form, those three tools also accept optional `bucket` + `skill_name` args; when both are supplied (and `bucket` is not `native`), a short relative `path`/`cwd` (e.g. `plugin.py`, `lib/utils.py`, or `.`) resolves under an existing `data/skills/<bucket>/<skill_name>/` via `decide_payload_short_form` / `synthesize_payload_constraint` in `ouroboros.contracts.skill_payload_policy` — the same `skill_repair`-mode resolution is reused, no new task-constraint mode is introduced. Explicit repo/data paths keep their own address space and ignore stale short-form args. Core/repo paths, `data/skills/native/*`, `data/state/skills/*`, marketplace/provenance sidecars, and direct `run_shell` writes to repo targets remain blocked.
|
||||
- New path checks for skill edits must use `ouroboros.contracts.skill_payload_policy` rather than reimplementing bucket/path traversal logic in each tool.
|
||||
|
||||
#### Page Header Layout
|
||||
|
|
|
|||
|
|
@ -94,18 +94,29 @@ def build_runtime_section(env: Any, task: Dict[str, Any]) -> str:
|
|||
|
||||
# --- Runtime context JSON ---
|
||||
_is_desktop = bool(os.environ.get("OUROBOROS_DESKTOP_MODE", ""))
|
||||
try:
|
||||
from ouroboros.config import get_runtime_mode
|
||||
runtime_mode = get_runtime_mode()
|
||||
except Exception:
|
||||
runtime_mode = os.environ.get("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
runtime_data = {
|
||||
"utc_now": utc_now_iso(),
|
||||
"repo_dir": str(env.repo_dir),
|
||||
"drive_root": str(env.drive_root),
|
||||
"git_head": git_sha,
|
||||
"git_branch": git_branch,
|
||||
"runtime_mode": runtime_mode,
|
||||
"task": {"id": task.get("id"), "type": task.get("type")},
|
||||
"runtime_env": {
|
||||
"is_desktop": _is_desktop,
|
||||
"platform": sys.platform,
|
||||
},
|
||||
}
|
||||
if str(runtime_mode).lower() == "light":
|
||||
runtime_data["runtime_mode_rule"] = (
|
||||
"light mode forbids Ouroboros repo mutation; scoped edits under "
|
||||
"data/skills/{external,clawhub,ouroboroshub}/<skill>/ remain allowed"
|
||||
)
|
||||
if budget_info:
|
||||
runtime_data["budget"] = budget_info
|
||||
runtime_ctx = json.dumps(runtime_data, ensure_ascii=False, indent=2)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,45 @@ class SkillPayloadTarget:
|
|||
control_plane: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayloadShortFormDecision:
|
||||
"""Resolution decision for optional ``bucket`` + ``skill_name`` edit args."""
|
||||
|
||||
constraint: Optional[TaskConstraint] = None
|
||||
error: str = ""
|
||||
ignored_reason: str = ""
|
||||
|
||||
|
||||
_OPTIONAL_ARG_SENTINELS = frozenset({
|
||||
"__omit__",
|
||||
"<omit>",
|
||||
"__none__",
|
||||
"<none>",
|
||||
"null",
|
||||
"none",
|
||||
"undefined",
|
||||
})
|
||||
|
||||
_DATA_ROOT_PREFIXES = frozenset({
|
||||
"archive",
|
||||
"logs",
|
||||
"memory",
|
||||
"skills",
|
||||
"state",
|
||||
"task_results",
|
||||
"uploads",
|
||||
})
|
||||
|
||||
_DATA_ROOT_FILENAMES = frozenset({
|
||||
"settings.json",
|
||||
})
|
||||
|
||||
|
||||
def _clean_optional_short_form_arg(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
return "" if text.lower() in _OPTIONAL_ARG_SENTINELS else text
|
||||
|
||||
|
||||
def _clean_data_rel(raw: str) -> str:
|
||||
norm = str(raw or "").replace("\\", "/").strip().lstrip("/")
|
||||
if norm.startswith("data/"):
|
||||
|
|
@ -190,8 +229,9 @@ def synthesize_payload_constraint(
|
|||
The semantic match is sufficient — both repair and light-mode short-form
|
||||
authoring confine the call to a single skill payload root.
|
||||
"""
|
||||
b = (bucket or "").strip()
|
||||
s = _sanitize_skill_name(skill_name)
|
||||
b = _clean_optional_short_form_arg(bucket)
|
||||
raw_skill_name = _clean_optional_short_form_arg(skill_name)
|
||||
s = _sanitize_skill_name(raw_skill_name)
|
||||
if not b or not s or s == "_unnamed":
|
||||
return None
|
||||
if b not in SKILL_PAYLOAD_BUCKETS:
|
||||
|
|
@ -203,6 +243,86 @@ def synthesize_payload_constraint(
|
|||
)
|
||||
|
||||
|
||||
def _explicit_path_kind(path_text: str, *, repo_dir: Path, drive_root: Path) -> str:
|
||||
raw = str(path_text or "").replace("\\", "/").strip()
|
||||
if raw in ("", ".", "./"):
|
||||
return ""
|
||||
drive = Path(drive_root).resolve(strict=False)
|
||||
repo = Path(repo_dir).resolve(strict=False)
|
||||
candidate = Path(raw)
|
||||
if candidate.is_absolute():
|
||||
resolved = candidate.resolve(strict=False)
|
||||
try:
|
||||
resolved.relative_to(repo)
|
||||
return "repo"
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
rel = resolved.relative_to(drive).as_posix()
|
||||
except ValueError:
|
||||
return ""
|
||||
return "skill" if rel.startswith("skills/") else "data"
|
||||
raw_lstripped = raw.lstrip("/")
|
||||
raw_lstripped_lower = raw_lstripped.lower()
|
||||
if raw_lstripped_lower.startswith("data/"):
|
||||
data_rel = raw_lstripped[len("data/"):]
|
||||
return "skill" if data_rel.lower().startswith("skills/") else "data"
|
||||
rel = _clean_data_rel(raw)
|
||||
rel_lower = rel.lower()
|
||||
if rel_lower.startswith("skills/"):
|
||||
return "skill"
|
||||
parts = PurePosixPath(rel).parts
|
||||
if not parts:
|
||||
return ""
|
||||
first_lower = parts[0].lower()
|
||||
if first_lower in _DATA_ROOT_PREFIXES or first_lower in _DATA_ROOT_FILENAMES:
|
||||
return "data"
|
||||
if (repo / parts[0]).exists():
|
||||
return "repo"
|
||||
return ""
|
||||
|
||||
|
||||
def decide_payload_short_form(
|
||||
*,
|
||||
bucket: str,
|
||||
skill_name: str,
|
||||
path_text: str,
|
||||
repo_dir: Path,
|
||||
drive_root: Path,
|
||||
) -> PayloadShortFormDecision:
|
||||
"""Resolve optional skill short-form args without overriding explicit paths."""
|
||||
clean_bucket = _clean_optional_short_form_arg(bucket)
|
||||
clean_skill_name = _clean_optional_short_form_arg(skill_name)
|
||||
if not clean_bucket and not clean_skill_name:
|
||||
return PayloadShortFormDecision()
|
||||
kind = _explicit_path_kind(path_text, repo_dir=repo_dir, drive_root=drive_root)
|
||||
if kind:
|
||||
return PayloadShortFormDecision(
|
||||
ignored_reason=(
|
||||
f"ignored bucket/skill_name because {path_text!r} is an explicit "
|
||||
f"{kind} path"
|
||||
)
|
||||
)
|
||||
synth = synthesize_payload_constraint(clean_bucket, clean_skill_name)
|
||||
if synth is None:
|
||||
return PayloadShortFormDecision(
|
||||
error=(
|
||||
"bucket and skill_name must be supplied together; bucket must be "
|
||||
"one of external/clawhub/ouroboroshub (native excluded); "
|
||||
"skill_name must sanitize to a non-empty slug."
|
||||
)
|
||||
)
|
||||
payload_root = (Path(drive_root) / synth.payload_root).resolve(strict=False)
|
||||
if not payload_root.is_dir():
|
||||
return PayloadShortFormDecision(
|
||||
error=(
|
||||
f"skill payload not found: {synth.payload_root}. "
|
||||
"Use an existing skill_name, or omit bucket/skill_name for a repo/data edit."
|
||||
)
|
||||
)
|
||||
return PayloadShortFormDecision(constraint=synth)
|
||||
|
||||
|
||||
def cross_skill_redirect_error(
|
||||
existing_tc: Optional[TaskConstraint],
|
||||
synth_tc: Optional[TaskConstraint],
|
||||
|
|
@ -241,6 +361,8 @@ __all__ = [
|
|||
"SKILL_PAYLOAD_CONTROL_DIRNAMES",
|
||||
"SkillPayloadPathError",
|
||||
"SkillPayloadTarget",
|
||||
"PayloadShortFormDecision",
|
||||
"decide_payload_short_form",
|
||||
"is_skill_payload_path",
|
||||
"resolve_skill_payload_target",
|
||||
"synthesize_payload_constraint",
|
||||
|
|
|
|||
|
|
@ -285,6 +285,16 @@ def _assert_tool_name(name: str) -> str:
|
|||
return candidate
|
||||
|
||||
|
||||
def _widget_span_from_render(render: Dict[str, Any]) -> int:
|
||||
"""Normalize optional UI-card width metadata from a render declaration."""
|
||||
raw = render.get("span", render.get("grid_span", 1))
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return 2 if value >= 2 else 1
|
||||
|
||||
|
||||
from ouroboros.extension_ui_validation import validate_ui_render as _validate_ui_render
|
||||
def _assert_ws_message_type(message_type: str) -> str:
|
||||
candidate = str(message_type or "").strip()
|
||||
|
|
@ -497,6 +507,8 @@ class PluginAPIImpl:
|
|||
self._require("widget")
|
||||
clean_tab = _assert_tool_name(tab_id) # same syntax rules
|
||||
key = f"{self._skill}:{clean_tab}"
|
||||
validated_render = _validate_ui_render(dict(render or {}))
|
||||
span = _widget_span_from_render(validated_render)
|
||||
with _lock:
|
||||
self._require_open_locked()
|
||||
if key in _ui_tabs:
|
||||
|
|
@ -509,7 +521,9 @@ class PluginAPIImpl:
|
|||
"title": str(title or clean_tab),
|
||||
"icon": str(icon or "extension"),
|
||||
"ws_prefix": extension_name_prefix(self._skill),
|
||||
"render": _validate_ui_render(dict(render or {})),
|
||||
"render": validated_render,
|
||||
"span": span,
|
||||
"grid_span": span,
|
||||
"ui_host_pending": True,
|
||||
}
|
||||
_extensions.setdefault(self._skill, _ExtensionRegistrations()).ui_tabs.append(key)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ _DECLARATIVE_WIDGET_COMPONENTS = {
|
|||
"image",
|
||||
"json",
|
||||
"kv",
|
||||
"key_value",
|
||||
"markdown",
|
||||
"poll",
|
||||
"progress",
|
||||
|
|
@ -132,6 +133,24 @@ def validate_ui_render(render: Dict[str, Any]) -> Dict[str, Any]:
|
|||
f"declarative widget component {idx} requires event or message_type"
|
||||
)
|
||||
_assert_ws_message_type(event_name)
|
||||
render_children = component.get("render", [])
|
||||
if render_children is not None and not isinstance(render_children, list):
|
||||
raise ExtensionRegistrationError(
|
||||
f"declarative widget component {idx} subscription render must be a list"
|
||||
)
|
||||
for child_idx, child in enumerate(render_children or []):
|
||||
child_type = str((child or {}).get("type") or "") if isinstance(child, dict) else ""
|
||||
if child_type in {"form", "action", "poll", "subscription", "stream", "tabs"}:
|
||||
raise ExtensionRegistrationError(
|
||||
f"declarative widget component {idx} subscription child {child_idx} "
|
||||
f"cannot use interactive type {child_type!r}"
|
||||
)
|
||||
if render_children:
|
||||
validate_ui_render({
|
||||
"kind": "declarative",
|
||||
"schema_version": schema_version,
|
||||
"components": render_children,
|
||||
})
|
||||
if component_type == "stream" and not str(component.get("route") or component.get("api_route") or "").strip():
|
||||
raise ExtensionRegistrationError(
|
||||
f"declarative widget component {idx} requires route or api_route"
|
||||
|
|
@ -195,6 +214,10 @@ def validate_ui_render(render: Dict[str, Any]) -> Dict[str, Any]:
|
|||
raise ExtensionRegistrationError(
|
||||
f"declarative widget component {idx} field {field_idx} requires path"
|
||||
)
|
||||
if component_type == "key_value" and not str(component.get("items_key") or component.get("path") or "").strip():
|
||||
raise ExtensionRegistrationError(
|
||||
f"declarative widget component {idx} key_value requires items_key or path"
|
||||
)
|
||||
if component_type == "table":
|
||||
columns = component.get("columns")
|
||||
if not isinstance(columns, list) or not columns:
|
||||
|
|
|
|||
|
|
@ -172,9 +172,10 @@ def _normalize_sdk_usage(usage: Any) -> Dict[str, Any]:
|
|||
# PreToolUse hook: path safety guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_path_guard(cwd: str):
|
||||
def make_path_guard(cwd: str, repo_root: str | None = None):
|
||||
"""Create a PreToolUse hook that blocks writes outside cwd and protected paths."""
|
||||
cwd_resolved = pathlib.Path(cwd).resolve()
|
||||
repo_root_resolved = pathlib.Path(repo_root).resolve() if repo_root else None
|
||||
|
||||
async def path_guard(input_data: dict, tool_use_id: str, context: Any) -> dict:
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
|
|
@ -210,9 +211,16 @@ def make_path_guard(cwd: str):
|
|||
}
|
||||
}
|
||||
|
||||
# Check: protected core/contract/release file?
|
||||
# Use pathlib.as_posix() for cross-platform forward-slash comparison
|
||||
# Check: protected core/contract/release file. Prefer repo-root
|
||||
# relative paths when available; cwd may be a subdirectory such as
|
||||
# ``repo/ouroboros`` where cwd-relative ``tools/registry.py`` would not
|
||||
# match the protected-path table.
|
||||
rel = target.relative_to(cwd_resolved).as_posix()
|
||||
if repo_root_resolved is not None:
|
||||
try:
|
||||
rel = target.relative_to(repo_root_resolved).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
from ouroboros.config import DATA_DIR
|
||||
from ouroboros.tools.core import is_skill_control_plane_path
|
||||
|
|
@ -287,12 +295,13 @@ async def _run_edit_async(
|
|||
max_turns: int = DEFAULT_CLAUDE_CODE_MAX_TURNS,
|
||||
budget: Optional[float] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
repo_root: Optional[str] = None,
|
||||
) -> ClaudeCodeResult:
|
||||
"""Run an edit-mode SDK query with safety hooks.
|
||||
|
||||
Uses ClaudeSDKClient because hooks require the client interface.
|
||||
"""
|
||||
path_guard = make_path_guard(cwd)
|
||||
path_guard = make_path_guard(cwd, repo_root=repo_root)
|
||||
clear_stderr_buffer()
|
||||
|
||||
options = ClaudeAgentOptions(
|
||||
|
|
@ -457,6 +466,7 @@ def run_edit(
|
|||
max_turns: int = DEFAULT_CLAUDE_CODE_MAX_TURNS,
|
||||
budget: Optional[float] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
repo_root: Optional[str] = None,
|
||||
) -> ClaudeCodeResult:
|
||||
"""Synchronous entry point for edit-mode SDK.
|
||||
|
||||
|
|
@ -470,6 +480,7 @@ def run_edit(
|
|||
max_turns=max_turns,
|
||||
budget=budget,
|
||||
system_prompt=system_prompt,
|
||||
repo_root=repo_root,
|
||||
))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ VALID_SKILL_REVIEW_STATUSES = frozenset({
|
|||
})
|
||||
|
||||
|
||||
CRITICAL_ITEMS = frozenset({
|
||||
HARD_CRITICAL_ITEMS = frozenset({
|
||||
"manifest_schema",
|
||||
"skill_preflight",
|
||||
"permissions_honesty",
|
||||
|
|
@ -41,11 +41,28 @@ CRITICAL_ITEMS = frozenset({
|
|||
"env_allowlist",
|
||||
"inject_chat_minimization",
|
||||
"event_subscription_minimization",
|
||||
"companion_process_safety",
|
||||
"host_token_handling",
|
||||
"bug_hunting",
|
||||
})
|
||||
|
||||
SEVERITY_DRIVEN_ITEMS = frozenset({
|
||||
"bug_hunting",
|
||||
"companion_process_safety",
|
||||
"extension_namespace_discipline",
|
||||
"widget_module_safety",
|
||||
})
|
||||
|
||||
# Backward-compatible export name for older imports. New aggregation logic
|
||||
# distinguishes hard trust-boundary blockers from severity-driven items below.
|
||||
CRITICAL_ITEMS = HARD_CRITICAL_ITEMS | SEVERITY_DRIVEN_ITEMS
|
||||
|
||||
|
||||
def _severity_blocks(finding: Dict[str, Any]) -> bool:
|
||||
severity = str(finding.get("severity") or "").strip().lower()
|
||||
if severity in {"advisory", "warning", "warn"}:
|
||||
return False
|
||||
# Missing/unknown severity stays conservative for legacy persisted findings.
|
||||
return True
|
||||
|
||||
|
||||
def aggregate_skill_review_status(
|
||||
findings: List[Dict[str, Any]],
|
||||
|
|
@ -63,11 +80,12 @@ def aggregate_skill_review_status(
|
|||
if not verdict:
|
||||
continue
|
||||
item = finding.get("item")
|
||||
item_is_critical = (
|
||||
item in CRITICAL_ITEMS
|
||||
or (item == "extension_namespace_discipline" and is_extension)
|
||||
or (item == "widget_module_safety" and is_extension)
|
||||
)
|
||||
item_is_critical = item in HARD_CRITICAL_ITEMS
|
||||
if item in SEVERITY_DRIVEN_ITEMS:
|
||||
if item in {"extension_namespace_discipline", "widget_module_safety"} and not is_extension:
|
||||
item_is_critical = False
|
||||
else:
|
||||
item_is_critical = _severity_blocks(finding)
|
||||
if item_is_critical:
|
||||
has_critical_fail = True
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ from ouroboros.contracts.skill_payload_policy import (
|
|||
SKILL_PAYLOAD_ALL_BUCKETS,
|
||||
SKILL_PAYLOAD_CONTROL_DIRNAMES,
|
||||
SKILL_PAYLOAD_CONTROL_FILENAMES,
|
||||
SkillPayloadPathError,
|
||||
cross_skill_redirect_error,
|
||||
synthesize_payload_constraint,
|
||||
decide_payload_short_form,
|
||||
resolve_skill_payload_target,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -109,6 +111,13 @@ def _native_payload_without_seed(target: pathlib.Path, data_root: pathlib.Path)
|
|||
return bucket == "native" and not (payload_root / ".seed-origin").is_file()
|
||||
|
||||
|
||||
def _data_skill_path(path: str, drive_root: pathlib.Path) -> pathlib.Path | None:
|
||||
try:
|
||||
return resolve_skill_payload_target(pathlib.Path(drive_root), path).target_path
|
||||
except SkillPayloadPathError:
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_serialized_tool_result(content: Any) -> bool:
|
||||
text = str(content or "").lstrip()
|
||||
if not (text.startswith("{'content'") or text.startswith('{"content"')):
|
||||
|
|
@ -395,13 +404,16 @@ def _data_write(
|
|||
# skill_repair-flavoured TaskConstraint so the existing payload-confined
|
||||
# write flow handles the resolution. This is the light-mode short-form
|
||||
# path described in DEVELOPMENT.md (Skill Repair Task Constraints).
|
||||
synth = synthesize_payload_constraint(bucket, skill_name)
|
||||
if (bucket or skill_name) and synth is None:
|
||||
return (
|
||||
"⚠️ DATA_WRITE_ERROR: bucket and skill_name must be supplied together; "
|
||||
"bucket must be one of external/clawhub/ouroboroshub (native excluded); "
|
||||
"skill_name must sanitize to a non-empty slug."
|
||||
)
|
||||
short_form = decide_payload_short_form(
|
||||
bucket=bucket,
|
||||
skill_name=skill_name,
|
||||
path_text=path,
|
||||
repo_dir=pathlib.Path(ctx.repo_dir),
|
||||
drive_root=pathlib.Path(ctx.drive_root),
|
||||
)
|
||||
if short_form.error:
|
||||
return f"⚠️ DATA_WRITE_ERROR: {short_form.error}"
|
||||
synth = short_form.constraint
|
||||
existing_tc = normalize_task_constraint(getattr(ctx, "task_constraint", None))
|
||||
redirect_err = cross_skill_redirect_error(existing_tc, synth)
|
||||
if redirect_err:
|
||||
|
|
@ -413,13 +425,15 @@ def _data_write(
|
|||
task_constraint = existing_tc
|
||||
else:
|
||||
task_constraint = synth or existing_tc
|
||||
write_path = _normalize_data_read_path(ctx, path)
|
||||
if task_constraint and task_constraint.mode == "skill_repair" and task_constraint.payload_root:
|
||||
try:
|
||||
p = resolve_payload_path(pathlib.Path(ctx.drive_root), task_constraint, path)
|
||||
except ValueError as e:
|
||||
return f"⚠️ DATA_WRITE_ERROR: {e}"
|
||||
else:
|
||||
p = ctx.drive_path(path)
|
||||
explicit_skill_target = _data_skill_path(path, pathlib.Path(ctx.drive_root))
|
||||
p = explicit_skill_target if explicit_skill_target is not None else ctx.drive_path(write_path)
|
||||
# v5.1.2 elevation ratchet defense-in-depth: settings.json is owner-only.
|
||||
# The chokepoint in ``ouroboros.config.save_settings`` already refuses
|
||||
# disk-level elevation; blocking ``data_write`` here turns the whole
|
||||
|
|
@ -440,7 +454,7 @@ def _data_write(
|
|||
if task_constraint and task_constraint.mode == "skill_repair" and task_constraint.payload_root:
|
||||
lexical_target = pathlib.Path(p).resolve(strict=False)
|
||||
else:
|
||||
lexical_target = pathlib.Path(ctx.drive_root).resolve(strict=False) / safe_relpath(path)
|
||||
lexical_target = pathlib.Path(ctx.drive_root).resolve(strict=False) / safe_relpath(write_path)
|
||||
suffix = pathlib.PurePosixPath(str(path or "")).suffix.lower()
|
||||
if suffix in {".py", ".md", ".json", ".sh"} and _looks_like_serialized_tool_result(content):
|
||||
return (
|
||||
|
|
@ -554,7 +568,10 @@ def _data_write(
|
|||
state_marker = pathlib.Path(ctx.drive_root) / "state" / "skills" / marker_payload[1] / "self_authored.json"
|
||||
state_marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(state_marker, marker_payload_data, trailing_newline=True)
|
||||
return f"OK: wrote {mode} {path} ({len(content)} chars)"
|
||||
result = f"OK: wrote {mode} {path} ({len(content)} chars)"
|
||||
if short_form.ignored_reason:
|
||||
result += f"\n⚠️ SKILL_SHORT_FORM_IGNORED: {short_form.ignored_reason}."
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1028,9 +1045,9 @@ def get_tools() -> List[ToolEntry]:
|
|||
"Write a UTF-8 text file to the local data directory. "
|
||||
"Use mode='append' to write a large file in chunks across multiple calls "
|
||||
"(useful when the full content exceeds a single LLM output budget). "
|
||||
"Optional bucket+skill_name args let runtime_mode=light tasks write a short "
|
||||
"relative path under data/skills/<bucket>/<skill_name>/ without an explicit "
|
||||
"task_constraint."
|
||||
"Optional bucket+skill_name args let tasks write a short relative path under "
|
||||
"an existing data/skills/<bucket>/<skill_name>/ payload. Explicit data/repo "
|
||||
"paths keep their own address space and ignore stale short-form args."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string"},
|
||||
|
|
@ -1039,11 +1056,11 @@ def get_tools() -> List[ToolEntry]:
|
|||
"bucket": {
|
||||
"type": "string",
|
||||
"enum": ["external", "clawhub", "ouroboroshub"],
|
||||
"description": "Skill payload bucket. Pair with skill_name to use a short relative path under data/skills/<bucket>/<skill_name>/. Requires both args together.",
|
||||
"description": "Skill payload bucket for short relative payload paths only. Pair with skill_name. Do not supply for explicit repo/data paths.",
|
||||
},
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Skill slug (sanitized to alnum/_-., ≤64 chars). Requires bucket.",
|
||||
"description": "Skill slug for short relative payload paths only. Requires bucket.",
|
||||
},
|
||||
}, "required": ["path", "content"]},
|
||||
}, _data_write),
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ from ouroboros.contracts.task_constraint import normalize_task_constraint, resol
|
|||
from ouroboros.contracts.skill_payload_policy import (
|
||||
SkillPayloadPathError,
|
||||
cross_skill_redirect_error,
|
||||
decide_payload_short_form,
|
||||
resolve_skill_payload_target,
|
||||
synthesize_payload_constraint,
|
||||
)
|
||||
_CONTENT_OMITTED_PREFIX = "<<CONTENT_OMITTED"
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -1055,13 +1055,16 @@ def _str_replace_editor(
|
|||
action="edit",
|
||||
)
|
||||
|
||||
synth = synthesize_payload_constraint(bucket, skill_name)
|
||||
if (bucket or skill_name) and synth is None:
|
||||
return (
|
||||
"⚠️ STR_REPLACE_ERROR: bucket and skill_name must be supplied together; "
|
||||
"bucket must be one of external/clawhub/ouroboroshub (native excluded); "
|
||||
"skill_name must sanitize to a non-empty slug."
|
||||
)
|
||||
short_form = decide_payload_short_form(
|
||||
bucket=bucket,
|
||||
skill_name=skill_name,
|
||||
path_text=path,
|
||||
repo_dir=pathlib.Path(ctx.repo_dir),
|
||||
drive_root=pathlib.Path(ctx.drive_root),
|
||||
)
|
||||
if short_form.error:
|
||||
return f"⚠️ STR_REPLACE_ERROR: {short_form.error}"
|
||||
synth = short_form.constraint
|
||||
existing_tc = normalize_task_constraint(getattr(ctx, "task_constraint", None))
|
||||
redirect_err = cross_skill_redirect_error(existing_tc, synth)
|
||||
if redirect_err:
|
||||
|
|
@ -1158,6 +1161,8 @@ def _str_replace_editor(
|
|||
f"Context:\n{context_preview}\n\n"
|
||||
"File is on disk but NOT committed."
|
||||
)
|
||||
if short_form.ignored_reason:
|
||||
result += f"\n⚠️ SKILL_SHORT_FORM_IGNORED: {short_form.ignored_reason}."
|
||||
if data_skill_target is None:
|
||||
result += "\nRun repo_commit when ready.\n⚠️ Advisory pre-review is now stale — run advisory_pre_review before repo_commit."
|
||||
else:
|
||||
|
|
@ -1719,8 +1724,9 @@ def get_tools() -> List[ToolEntry]:
|
|||
"Safer than repo_write for existing files — reads the file, verifies the match is unique, "
|
||||
"performs the replacement, and shows context. Use for all edits to existing tracked files. "
|
||||
"For new files or intentional full rewrites, use repo_write instead. "
|
||||
"Optional bucket+skill_name args let runtime_mode=light tasks address a short relative "
|
||||
"path under data/skills/<bucket>/<skill_name>/ without an explicit task_constraint."
|
||||
"Optional bucket+skill_name args let tasks address a short relative path under an "
|
||||
"existing data/skills/<bucket>/<skill_name>/ payload. Explicit repo/data paths "
|
||||
"keep their own address space and ignore stale short-form args."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "File path relative to repo root"},
|
||||
|
|
@ -1729,11 +1735,11 @@ def get_tools() -> List[ToolEntry]:
|
|||
"bucket": {
|
||||
"type": "string",
|
||||
"enum": ["external", "clawhub", "ouroboroshub"],
|
||||
"description": "Skill payload bucket. Pair with skill_name to resolve a short relative path under data/skills/<bucket>/<skill_name>/. Requires both args together.",
|
||||
"description": "Skill payload bucket for short relative payload paths only. Pair with skill_name. Do not supply for explicit repo/data paths.",
|
||||
},
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Skill slug (sanitized to alnum/_-., ≤64 chars). Requires bucket.",
|
||||
"description": "Skill slug for short relative payload paths only. Requires bucket.",
|
||||
},
|
||||
}, "required": ["path", "old_str", "new_str"]},
|
||||
}, _str_replace_editor, is_code_tool=True),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ ToolRegistry collects all tools, provides schemas() and execute().
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -36,8 +37,8 @@ from ouroboros.utils import safe_relpath
|
|||
from ouroboros.contracts.task_constraint import TaskConstraint, normalize_task_constraint, resolve_payload_path
|
||||
from ouroboros.contracts.skill_payload_policy import (
|
||||
cross_skill_redirect_error,
|
||||
decide_payload_short_form,
|
||||
is_skill_payload_path,
|
||||
synthesize_payload_constraint,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -328,6 +329,83 @@ _GIT_READONLY_SUBCOMMANDS = frozenset([
|
|||
"grep", "reflog", "fetch",
|
||||
])
|
||||
|
||||
|
||||
def _parse_porcelain_paths(output: str) -> list[str]:
|
||||
paths: list[str] = []
|
||||
for raw_line in str(output or "").splitlines():
|
||||
# Porcelain v1 uses two status columns; a leading space is meaningful
|
||||
# (" M README.md"). Do not strip the left side before slicing.
|
||||
line = raw_line.rstrip()
|
||||
if len(line) < 4:
|
||||
continue
|
||||
path_text = line[3:].strip()
|
||||
if " -> " in path_text:
|
||||
old_path, new_path = path_text.rsplit(" -> ", 1)
|
||||
paths.extend([old_path.strip(), new_path.strip()])
|
||||
else:
|
||||
paths.append(path_text)
|
||||
return sorted({p for p in paths if p})
|
||||
|
||||
|
||||
def _light_repo_snapshot(repo_dir: pathlib.Path) -> Optional[Dict[str, Any]]:
|
||||
"""Return a deterministic worktree snapshot for light-mode write detection.
|
||||
|
||||
This is a tripwire, not rollback machinery. It intentionally observes the
|
||||
real Ouroboros repo root (``ctx.repo_dir``) so absolute writes are caught
|
||||
even when ``run_shell`` executes from another cwd.
|
||||
"""
|
||||
try:
|
||||
repo = pathlib.Path(repo_dir)
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if status.returncode != 0:
|
||||
return None
|
||||
unstaged = subprocess.run(
|
||||
["git", "diff", "--binary", "--no-ext-diff"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
staged = subprocess.run(
|
||||
["git", "diff", "--cached", "--binary", "--no-ext-diff"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
paths = _parse_porcelain_paths(status.stdout)
|
||||
digest = hashlib.sha256()
|
||||
digest.update((status.stdout or "").encode("utf-8", errors="replace"))
|
||||
digest.update((unstaged.stdout if unstaged.returncode == 0 else "").encode("utf-8", errors="replace"))
|
||||
digest.update((staged.stdout if staged.returncode == 0 else "").encode("utf-8", errors="replace"))
|
||||
for rel in paths:
|
||||
try:
|
||||
target = (repo / safe_relpath(rel)).resolve(strict=False)
|
||||
target.relative_to(repo.resolve(strict=False))
|
||||
if target.is_file() and rel in (status.stdout or ""):
|
||||
stat = target.stat()
|
||||
digest.update(f"{rel}\0{stat.st_size}\0{stat.st_mtime_ns}".encode("utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
return {"digest": digest.hexdigest(), "paths": paths}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _format_light_repo_write_block(before: Dict[str, Any], after: Dict[str, Any], result: str) -> str:
|
||||
before_paths = set(before.get("paths") or [])
|
||||
after_paths = set(after.get("paths") or [])
|
||||
touched = sorted(after_paths | before_paths)
|
||||
listed = ", ".join(touched[:30]) if touched else "(status changed; no paths parsed)"
|
||||
if len(touched) > 30:
|
||||
listed += f", ... (+{len(touched) - 30} more)"
|
||||
return (
|
||||
"⚠️ LIGHT_MODE_REPO_WRITE_BLOCKED: runtime_mode=light detected "
|
||||
"a mutation of the Ouroboros repository after run_shell. "
|
||||
"The command result is blocked and no automatic rollback was attempted "
|
||||
"to avoid overwriting concurrent human edits. "
|
||||
f"Affected/dirty paths: {listed}. Switch to advanced/pro for repo writes.\n\n"
|
||||
"Original command output:\n"
|
||||
f"{result}"
|
||||
)
|
||||
|
||||
def _revert_protected_files(repo_dir, *, runtime_mode: str = "advanced") -> list:
|
||||
"""After claude_code_edit, revert protected files unless pro mode is active."""
|
||||
if mode_allows_protected_write(runtime_mode):
|
||||
|
|
@ -1014,6 +1092,33 @@ class ToolRegistry:
|
|||
pass
|
||||
return changed
|
||||
|
||||
def _run_shell_post_checks(
|
||||
self,
|
||||
result: str,
|
||||
*,
|
||||
owner_snapshot: Dict[pathlib.Path, Optional[str]],
|
||||
light_repo_before: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
import time
|
||||
|
||||
restored_owner_state = False
|
||||
for _ in range(4):
|
||||
time.sleep(0.3)
|
||||
restored_owner_state = self._restore_owner_files(owner_snapshot) or restored_owner_state
|
||||
if restored_owner_state:
|
||||
result = (
|
||||
f"{result}\n\n⚠️ OWNER_STATE_RESTORED: run_shell attempted to "
|
||||
"change owner-only settings or skill trust state; protected files were restored."
|
||||
)
|
||||
if light_repo_before is not None:
|
||||
light_repo_after = _light_repo_snapshot(pathlib.Path(self._ctx.repo_dir))
|
||||
if (
|
||||
light_repo_after is not None
|
||||
and light_repo_after.get("digest") != light_repo_before.get("digest")
|
||||
):
|
||||
result = _format_light_repo_write_block(light_repo_before, light_repo_after, result)
|
||||
return result
|
||||
|
||||
def execute(self, name: str, args: Dict[str, Any]) -> str:
|
||||
requested_name = str(name or "").strip()
|
||||
name = canonical_tool_name(requested_name)
|
||||
|
|
@ -1138,12 +1243,17 @@ class ToolRegistry:
|
|||
"stage_pr_merge",
|
||||
}
|
||||
)
|
||||
# bucket+skill_name args (light-mode short-form authoring) synthesize
|
||||
# a skill_repair-flavoured constraint so the gate treats the call as a
|
||||
# payload-confined edit just like an explicit skill_repair task would.
|
||||
raw_bucket = str(args.get("bucket", "") or "")
|
||||
raw_skill_name = str(args.get("skill_name", "") or "")
|
||||
synth_constraint = synthesize_payload_constraint(raw_bucket, raw_skill_name)
|
||||
short_path_text = str(args.get("cwd") if name == "claude_code_edit" else args.get("path", "") or "")
|
||||
short_form_decision = decide_payload_short_form(
|
||||
bucket=raw_bucket,
|
||||
skill_name=raw_skill_name,
|
||||
path_text=short_path_text or ".",
|
||||
repo_dir=pathlib.Path(self._ctx.repo_dir),
|
||||
drive_root=pathlib.Path(self._ctx.drive_root),
|
||||
)
|
||||
synth_constraint = short_form_decision.constraint
|
||||
# Surface a specific partial-args error BEFORE the generic light-mode
|
||||
# block, so an agent that supplied only one of {bucket, skill_name}
|
||||
# (or chose `native`) sees the actionable wording promised in
|
||||
|
|
@ -1151,19 +1261,14 @@ class ToolRegistry:
|
|||
# LIGHT_MODE_BLOCKED that lists three escape hatches.
|
||||
if (
|
||||
(raw_bucket or raw_skill_name)
|
||||
and synth_constraint is None
|
||||
and short_form_decision.error
|
||||
and name in (
|
||||
"data_write",
|
||||
"str_replace_editor",
|
||||
"claude_code_edit",
|
||||
)
|
||||
):
|
||||
return (
|
||||
"⚠️ SKILL_PAYLOAD_ARG_ERROR: bucket and skill_name must be "
|
||||
"supplied together; bucket must be one of "
|
||||
"external/clawhub/ouroboroshub (native excluded); "
|
||||
"skill_name must sanitize to a non-empty slug."
|
||||
)
|
||||
return f"⚠️ SKILL_PAYLOAD_ARG_ERROR: {short_form_decision.error}"
|
||||
# Repair-mode confinement is sticky: a real skill_repair task_constraint
|
||||
# MUST win over a synthesized one. Otherwise an agent active in heal
|
||||
# mode for skill A could redirect a write/edit to skill B by passing
|
||||
|
|
@ -1266,6 +1371,11 @@ class ToolRegistry:
|
|||
return safety_msg
|
||||
|
||||
owner_snapshot = self._snapshot_owner_files() if name == "run_shell" else {}
|
||||
light_repo_before = (
|
||||
_light_repo_snapshot(pathlib.Path(self._ctx.repo_dir))
|
||||
if name == "run_shell" and _runtime_mode == "light"
|
||||
else None
|
||||
)
|
||||
try:
|
||||
result = entry.handler(self._ctx, **args)
|
||||
except TypeError as e:
|
||||
|
|
@ -1273,16 +1383,11 @@ class ToolRegistry:
|
|||
except Exception as e:
|
||||
return f"⚠️ TOOL_ERROR ({name}): {e}"
|
||||
if name == "run_shell":
|
||||
import time
|
||||
restored_owner_state = False
|
||||
for _ in range(4):
|
||||
time.sleep(0.3)
|
||||
restored_owner_state = self._restore_owner_files(owner_snapshot) or restored_owner_state
|
||||
if restored_owner_state:
|
||||
result = (
|
||||
f"{result}\n\n⚠️ OWNER_STATE_RESTORED: run_shell attempted to "
|
||||
"change owner-only settings or skill trust state; protected files were restored."
|
||||
)
|
||||
result = self._run_shell_post_checks(
|
||||
result,
|
||||
owner_snapshot=owner_snapshot,
|
||||
light_repo_before=light_repo_before,
|
||||
)
|
||||
|
||||
# Revert protected files after claude_code_edit unless pro mode is
|
||||
# active; pro-mode commits still require the normal commit review later.
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ from ouroboros.contracts.task_constraint import normalize_task_constraint
|
|||
from ouroboros.contracts.skill_payload_policy import (
|
||||
SkillPayloadPathError,
|
||||
cross_skill_redirect_error,
|
||||
decide_payload_short_form,
|
||||
resolve_skill_payload_target,
|
||||
synthesize_payload_constraint,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -479,13 +479,17 @@ def _claude_code_edit(ctx: ToolContext, prompt: str, cwd: str = "",
|
|||
|
||||
work_dir = str(ctx.repo_dir)
|
||||
skill_payload_root = None
|
||||
synth = synthesize_payload_constraint(bucket, skill_name)
|
||||
if (bucket or skill_name) and synth is None:
|
||||
return (
|
||||
"⚠️ CLAUDE_CODE_ERROR: bucket and skill_name must be supplied together; "
|
||||
"bucket must be one of external/clawhub/ouroboroshub (native excluded); "
|
||||
"skill_name must sanitize to a non-empty slug."
|
||||
)
|
||||
short_form_path_text = cwd if str(cwd or "").strip() else str(ctx.repo_dir)
|
||||
short_form = decide_payload_short_form(
|
||||
bucket=bucket,
|
||||
skill_name=skill_name,
|
||||
path_text=short_form_path_text,
|
||||
repo_dir=pathlib.Path(ctx.repo_dir),
|
||||
drive_root=pathlib.Path(ctx.drive_root),
|
||||
)
|
||||
if short_form.error:
|
||||
return f"⚠️ CLAUDE_CODE_ERROR: {short_form.error}"
|
||||
synth = short_form.constraint
|
||||
existing_tc = normalize_task_constraint(getattr(ctx, "task_constraint", None))
|
||||
redirect_err = cross_skill_redirect_error(existing_tc, synth)
|
||||
if redirect_err:
|
||||
|
|
@ -518,12 +522,28 @@ def _claude_code_edit(ctx: ToolContext, prompt: str, cwd: str = "",
|
|||
normalized_cwd = raw_cwd.replace("\\", "/").strip().lstrip("/")
|
||||
if normalized_cwd.startswith("data/skills/") or normalized_cwd.startswith("skills/"):
|
||||
return f"⚠️ CLAUDE_CODE_ERROR: skill cwd is invalid: {exc}"
|
||||
raw_path = pathlib.Path(raw_cwd)
|
||||
candidate_for_data_check = (
|
||||
raw_path.resolve(strict=False)
|
||||
if raw_path.is_absolute()
|
||||
else (pathlib.Path(ctx.repo_dir) / raw_cwd).resolve(strict=False)
|
||||
)
|
||||
try:
|
||||
pathlib.Path(raw_cwd).resolve(strict=False).relative_to(pathlib.Path(ctx.drive_root).resolve(strict=False))
|
||||
candidate_for_data_check.relative_to(pathlib.Path(ctx.repo_dir).resolve(strict=False))
|
||||
candidate_is_repo = True
|
||||
except ValueError:
|
||||
candidate_is_repo = False
|
||||
try:
|
||||
candidate_for_data_check.relative_to(pathlib.Path(ctx.drive_root).resolve(strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return f"⚠️ CLAUDE_CODE_ERROR: skill cwd is invalid: {exc}"
|
||||
if not candidate_is_repo:
|
||||
return (
|
||||
"⚠️ CLAUDE_CODE_ERROR: non-skill data cwd is not allowed. "
|
||||
"Use explicit data/skills/<bucket>/<skill>/... for skill payload edits, "
|
||||
"or omit cwd/use a repo cwd for repo edits."
|
||||
)
|
||||
candidate = (ctx.repo_dir / raw_cwd).resolve()
|
||||
if not candidate.exists() or not candidate.is_dir():
|
||||
return f"⚠️ CLAUDE_CODE_ERROR: cwd not found or not a directory: {cwd}"
|
||||
|
|
@ -576,6 +596,7 @@ def _claude_code_edit(ctx: ToolContext, prompt: str, cwd: str = "",
|
|||
max_turns=DEFAULT_CLAUDE_CODE_MAX_TURNS,
|
||||
budget=budget,
|
||||
system_prompt=system_prompt,
|
||||
repo_root=str(ctx.repo_dir),
|
||||
)
|
||||
|
||||
result.changed_files = _get_changed_files(target_repo_root)
|
||||
|
|
@ -631,7 +652,10 @@ def _claude_code_edit(ctx: ToolContext, prompt: str, cwd: str = "",
|
|||
source_tool="claude_code_edit",
|
||||
)
|
||||
|
||||
return result.to_tool_output()
|
||||
output = result.to_tool_output()
|
||||
if short_form.ignored_reason:
|
||||
output += f"\n\n⚠️ SKILL_SHORT_FORM_IGNORED: {short_form.ignored_reason}."
|
||||
return output
|
||||
|
||||
except ImportError:
|
||||
return (
|
||||
|
|
@ -697,8 +721,9 @@ def get_tools() -> List[ToolEntry]:
|
|||
"many str_replace_editor calls. It also subdivides very large writes across "
|
||||
"many small Write/Edit operations inside its own agent loop, so use it for "
|
||||
"files larger than a single LLM output can produce. Follow with repo_commit. "
|
||||
"Optional bucket+skill_name args let runtime_mode=light tasks anchor cwd "
|
||||
"under data/skills/<bucket>/<skill_name>/ without an explicit task_constraint."
|
||||
"Optional bucket+skill_name args let tasks anchor a short relative cwd under "
|
||||
"an existing data/skills/<bucket>/<skill_name>/ payload. Explicit repo/data "
|
||||
"cwd values keep their own address space and ignore stale short-form args."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"prompt": {"type": "string"},
|
||||
|
|
@ -710,11 +735,11 @@ def get_tools() -> List[ToolEntry]:
|
|||
"bucket": {
|
||||
"type": "string",
|
||||
"enum": ["external", "clawhub", "ouroboroshub"],
|
||||
"description": "Skill payload bucket. Pair with skill_name to resolve cwd under data/skills/<bucket>/<skill_name>/. Requires both args together.",
|
||||
"description": "Skill payload bucket for short relative payload cwd only. Pair with skill_name. Do not supply for explicit repo/data cwd values.",
|
||||
},
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Skill slug (sanitized to alnum/_-., ≤64 chars). Requires bucket.",
|
||||
"description": "Skill slug for short relative payload cwd only. Requires bucket.",
|
||||
},
|
||||
}, "required": ["prompt"]},
|
||||
}, _claude_code_edit, is_code_tool=True, timeout_sec=1200),
|
||||
|
|
|
|||
|
|
@ -129,15 +129,15 @@ blocked. Then run
|
|||
call shell, browser/search, scheduling, skill execution, toggle/enable, repo
|
||||
commit, or extension tools in repair mode; the registry enforces this.
|
||||
|
||||
**Under `runtime_mode=light` without a repair constraint**, ordinary chat
|
||||
tasks may still create or edit skills under
|
||||
`data/skills/{external,clawhub,ouroboroshub}/<skill>/` via two short forms:
|
||||
either pass an explicit path (`data/skills/<bucket>/<skill>/...`), or pass
|
||||
`bucket` + `skill_name` args to `data_write` / `str_replace_editor` /
|
||||
`claude_code_edit`; in the second form a short relative `path`/`cwd`
|
||||
(`plugin.py`, `lib/utils.py`, `.`) resolves under
|
||||
`data/skills/<bucket>/<skill_name>/`. `native` is excluded — the launcher
|
||||
seed update lane stays authoritative.
|
||||
**Runtime mode matters.** The current mode is in Runtime context. In
|
||||
`runtime_mode=light`, core repo mutation is forbidden. Skill payload edits
|
||||
under `data/skills/{external,clawhub,ouroboroshub}/<skill>/` remain allowed.
|
||||
Outside repair mode, use either an explicit payload path
|
||||
(`data/skills/<bucket>/<skill>/...`) or `bucket` + `skill_name` with a short
|
||||
relative `path`/`cwd` (`plugin.py`, `lib/utils.py`, `.`). Explicit repo/data
|
||||
paths keep their own address space: do not carry stale `bucket`/`skill_name`
|
||||
into repo edits. `native` is excluded — the launcher seed update lane stays
|
||||
authoritative.
|
||||
|
||||
**For files larger than a single LLM output** (heavy payload modules,
|
||||
generated assets, etc.), do not reach for `run_shell` heredoc — every
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "ouroboros"
|
||||
version = "5.20.1rc2"
|
||||
version = "5.21.0"
|
||||
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@ import json
|
|||
import pathlib
|
||||
import tempfile
|
||||
|
||||
from ouroboros.context import build_health_invariants
|
||||
from ouroboros.context import build_health_invariants, build_runtime_section
|
||||
|
||||
|
||||
class TestCacheHitRateInvariant:
|
||||
|
|
@ -88,6 +88,26 @@ def _make_health_env(tmp_path, events_lines=None):
|
|||
return FakeEnv()
|
||||
|
||||
|
||||
def test_runtime_section_includes_light_runtime_mode_rule(tmp_path, monkeypatch):
|
||||
env = _make_health_env(tmp_path)
|
||||
monkeypatch.setattr("ouroboros.config.get_runtime_mode", lambda: "light")
|
||||
section = build_runtime_section(env, {"id": "task-1", "type": "task"})
|
||||
payload = json.loads(section.split("\n\n", 1)[1])
|
||||
|
||||
assert payload["runtime_mode"] == "light"
|
||||
assert "forbids Ouroboros repo mutation" in payload["runtime_mode_rule"]
|
||||
|
||||
|
||||
def test_runtime_section_omits_light_rule_for_advanced(tmp_path, monkeypatch):
|
||||
env = _make_health_env(tmp_path)
|
||||
monkeypatch.setattr("ouroboros.config.get_runtime_mode", lambda: "advanced")
|
||||
section = build_runtime_section(env, {"id": "task-1", "type": "task"})
|
||||
payload = json.loads(section.split("\n\n", 1)[1])
|
||||
|
||||
assert payload["runtime_mode"] == "advanced"
|
||||
assert "runtime_mode_rule" not in payload
|
||||
|
||||
|
||||
class TestFileSizeBudgetHealthInvariant:
|
||||
def _make_env(self, tmp_path, development_text: str):
|
||||
class FakeEnv:
|
||||
|
|
|
|||
|
|
@ -919,12 +919,32 @@ def test_register_ui_tab_surfaces_hostable_widget(tmp_path):
|
|||
assert snap["ui_tabs"][0]["key"] == "uiwait:weather"
|
||||
assert snap["ui_tabs"][0]["ws_prefix"] == extension_loader.extension_name_prefix("uiwait")
|
||||
assert snap["ui_tabs"][0]["render"]["kind"] == "declarative"
|
||||
assert snap["ui_tabs"][0]["span"] == 1
|
||||
assert snap["ui_tabs"][0]["grid_span"] == 1
|
||||
|
||||
extension_loader.unload_extension("uiwait")
|
||||
snap = extension_loader.snapshot()
|
||||
assert snap["ui_tabs"] == []
|
||||
|
||||
|
||||
def test_register_ui_tab_promotes_render_span_metadata(tmp_path):
|
||||
loaded, _, drive_root = _prepare_extension(
|
||||
tmp_path,
|
||||
"wideui",
|
||||
"def register(api):\n"
|
||||
" api.register_ui_tab('wide', 'Wide', render={'kind': 'declarative', 'schema_version': 1, 'span': 2, 'components': [{'type': 'markdown', 'text': 'ok'}]})\n",
|
||||
permissions=["widget"],
|
||||
)
|
||||
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
|
||||
assert err is None, err
|
||||
snap = extension_loader.snapshot()
|
||||
assert snap["ui_tabs"][0]["span"] == 2
|
||||
assert snap["ui_tabs"][0]["grid_span"] == 2
|
||||
assert snap["ui_tabs"][0]["render"]["span"] == 2
|
||||
|
||||
extension_loader.unload_extension("wideui")
|
||||
|
||||
|
||||
_UI_TAB_REJECTION_CASES = [
|
||||
(
|
||||
"unsupported_render_kind",
|
||||
|
|
@ -1000,6 +1020,21 @@ def test_register_ui_tab_accepts_subscription_component(tmp_path):
|
|||
assert snap["ui_tabs"][0]["render"]["components"][0]["type"] == "subscription"
|
||||
|
||||
|
||||
def test_register_ui_tab_accepts_subscription_render_children(tmp_path):
|
||||
loaded, _, drive_root = _prepare_extension(
|
||||
tmp_path,
|
||||
"subrender",
|
||||
"def register(api):\n"
|
||||
" api.register_ui_tab('sub', 'Sub', render={'kind': 'declarative', 'schema_version': 1, 'components': [{'type': 'subscription', 'event': 'progress', 'target': 'result', 'render': [{'type': 'progress', 'value_key': 'progress_pct', 'label_key': 'message'}, {'type': 'gallery', 'items_key': 'frames', 'item_type': 'image', 'route_prefix': 'asset?path='}, {'type': 'key_value', 'items_key': 'stats'}]}]})\n",
|
||||
permissions=["widget"],
|
||||
)
|
||||
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
|
||||
assert err is None, err
|
||||
component = extension_loader.snapshot()["ui_tabs"][0]["render"]["components"][0]
|
||||
assert component["type"] == "subscription"
|
||||
assert [item["type"] for item in component["render"]] == ["progress", "gallery", "key_value"]
|
||||
|
||||
|
||||
def test_register_ui_tab_accepts_widget_v2_components(tmp_path):
|
||||
loaded, _, drive_root = _prepare_extension(
|
||||
tmp_path,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import ast
|
|||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -888,6 +889,75 @@ def test_light_mode_allows_shell_wrapper_non_repo_writer(tmp_path, monkeypatch):
|
|||
assert "LIGHT_MODE_BLOCKED" not in result, result[:200]
|
||||
|
||||
|
||||
def test_light_mode_tripwire_catches_python_repo_writer(tmp_path, monkeypatch):
|
||||
import ouroboros.safety as safety_mod
|
||||
|
||||
repo = _git_repo(tmp_path)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "light")
|
||||
monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, ""))
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=tmp_path / "drive")
|
||||
|
||||
result = reg.execute(
|
||||
"run_shell",
|
||||
{"cmd": [sys.executable, "-c", "from pathlib import Path; Path('README.md').write_text('hacked\\n')"]},
|
||||
)
|
||||
|
||||
assert "LIGHT_MODE_REPO_WRITE_BLOCKED" in result, result[:300]
|
||||
assert "README.md" in result
|
||||
assert (repo / "README.md").read_text(encoding="utf-8") == "hacked\n"
|
||||
|
||||
|
||||
def test_light_mode_tripwire_catches_untracked_repo_file(tmp_path, monkeypatch):
|
||||
import ouroboros.safety as safety_mod
|
||||
|
||||
repo = _git_repo(tmp_path)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "light")
|
||||
monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, ""))
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=tmp_path / "drive")
|
||||
|
||||
result = reg.execute(
|
||||
"run_shell",
|
||||
{"cmd": [sys.executable, "-c", "from pathlib import Path; Path('new_tool.py').write_text('x\\n')"]},
|
||||
)
|
||||
|
||||
assert "LIGHT_MODE_REPO_WRITE_BLOCKED" in result, result[:300]
|
||||
assert "new_tool.py" in result
|
||||
assert (repo / "new_tool.py").read_text(encoding="utf-8") == "x\n"
|
||||
|
||||
|
||||
def test_light_mode_tripwire_runs_after_failed_command(tmp_path, monkeypatch):
|
||||
import ouroboros.safety as safety_mod
|
||||
|
||||
repo = _git_repo(tmp_path)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "light")
|
||||
monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, ""))
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=tmp_path / "drive")
|
||||
|
||||
result = reg.execute(
|
||||
"run_shell",
|
||||
{"cmd": [sys.executable, "-c", "from pathlib import Path; Path('README.md').write_text('bad\\n'); raise SystemExit(2)"]},
|
||||
)
|
||||
|
||||
assert "LIGHT_MODE_REPO_WRITE_BLOCKED" in result, result[:300]
|
||||
assert "SHELL_EXIT_ERROR" in result
|
||||
|
||||
|
||||
def test_advanced_mode_does_not_run_light_tripwire(tmp_path, monkeypatch):
|
||||
import ouroboros.safety as safety_mod
|
||||
|
||||
repo = _git_repo(tmp_path)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, ""))
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=tmp_path / "drive")
|
||||
|
||||
result = reg.execute(
|
||||
"run_shell",
|
||||
{"cmd": [sys.executable, "-c", "from pathlib import Path; Path('README.md').write_text('advanced\\n')"]},
|
||||
)
|
||||
|
||||
assert "LIGHT_MODE_REPO_WRITE_BLOCKED" not in result, result[:300]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Part: light-mode bucket+skill_name short-form authoring (v5.16.0-rc.1)
|
||||
# ===========================================================================
|
||||
|
|
@ -1167,12 +1237,136 @@ def test_synthesize_payload_constraint_unit():
|
|||
# Name that sanitizes away to nothing.
|
||||
assert synthesize_payload_constraint("external", "....") is None
|
||||
assert synthesize_payload_constraint("external", "/") is None
|
||||
assert synthesize_payload_constraint("external", "__omit__") is None
|
||||
|
||||
# Sanitizer normalises odd input but still returns a usable constraint.
|
||||
tc = synthesize_payload_constraint("external", "weather/v2")
|
||||
assert tc is not None and tc.skill_name == "weather_v2"
|
||||
|
||||
|
||||
def test_repo_path_wins_over_stale_bucket_skill_name(tmp_path, monkeypatch):
|
||||
repo = _git_repo(tmp_path)
|
||||
drive = tmp_path / "drive"
|
||||
(drive / "skills" / "external" / "alpha").mkdir(parents=True)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=drive)
|
||||
|
||||
result = reg.execute(
|
||||
"str_replace_editor",
|
||||
{
|
||||
"path": "README.md",
|
||||
"old_str": "ok",
|
||||
"new_str": "repo-ok",
|
||||
"bucket": "external",
|
||||
"skill_name": "alpha",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Replaced" in result, result[:300]
|
||||
assert "SKILL_SHORT_FORM_IGNORED" in result
|
||||
assert (repo / "README.md").read_text(encoding="utf-8") == "repo-ok\n"
|
||||
assert not (drive / "skills" / "external" / "alpha" / "README.md").exists()
|
||||
|
||||
|
||||
def test_data_settings_path_wins_over_stale_bucket_skill_name(tmp_path, monkeypatch):
|
||||
from ouroboros import config as cfg
|
||||
|
||||
drive = tmp_path / "drive"
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(drive / "skills" / "external" / "alpha").mkdir(parents=True)
|
||||
(drive / "settings.json").write_text('{"TOTAL_BUDGET": 10}\n', encoding="utf-8")
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
monkeypatch.setattr(cfg, "DATA_DIR", drive)
|
||||
monkeypatch.setattr(cfg, "SETTINGS_PATH", drive / "settings.json")
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=drive)
|
||||
|
||||
result = reg.execute(
|
||||
"data_write",
|
||||
{
|
||||
"path": "settings.json",
|
||||
"content": "{}\n",
|
||||
"bucket": "external",
|
||||
"skill_name": "alpha",
|
||||
},
|
||||
)
|
||||
|
||||
assert "DATA_WRITE_BLOCKED" in result, result[:300]
|
||||
assert not (drive / "skills" / "external" / "alpha" / "settings.json").exists()
|
||||
assert (drive / "settings.json").read_text(encoding="utf-8") == '{"TOTAL_BUDGET": 10}\n'
|
||||
|
||||
|
||||
def test_data_settings_case_variant_wins_over_stale_bucket_skill_name(tmp_path, monkeypatch):
|
||||
from ouroboros import config as cfg
|
||||
|
||||
drive = tmp_path / "drive"
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(drive / "skills" / "external" / "alpha").mkdir(parents=True)
|
||||
(drive / "settings.json").write_text('{"TOTAL_BUDGET": 10}\n', encoding="utf-8")
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
monkeypatch.setattr(cfg, "DATA_DIR", drive)
|
||||
monkeypatch.setattr(cfg, "SETTINGS_PATH", drive / "settings.json")
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=drive)
|
||||
|
||||
result = reg.execute(
|
||||
"data_write",
|
||||
{
|
||||
"path": "Settings.json",
|
||||
"content": "{}\n",
|
||||
"bucket": "external",
|
||||
"skill_name": "alpha",
|
||||
},
|
||||
)
|
||||
|
||||
assert "DATA_WRITE_BLOCKED" in result, result[:300]
|
||||
assert not (drive / "skills" / "external" / "alpha" / "Settings.json").exists()
|
||||
|
||||
|
||||
def test_explicit_data_skills_path_wins_over_stale_bucket_skill_name(tmp_path, monkeypatch):
|
||||
drive = tmp_path / "drive"
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
skill = drive / "skills" / "external" / "alpha"
|
||||
skill.mkdir(parents=True)
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
reg = ToolRegistry(repo_dir=repo, drive_root=drive)
|
||||
|
||||
result = reg.execute(
|
||||
"data_write",
|
||||
{
|
||||
"path": "data/skills/external/alpha/plugin.py",
|
||||
"content": "VALUE = 1\n",
|
||||
"bucket": "external",
|
||||
"skill_name": "alpha",
|
||||
},
|
||||
)
|
||||
|
||||
assert "DATA_WRITE_ERROR" not in result, result[:300]
|
||||
assert "SKILL_SHORT_FORM_IGNORED" in result
|
||||
assert (skill / "plugin.py").read_text(encoding="utf-8") == "VALUE = 1\n"
|
||||
assert not (drive / "data" / "skills" / "external" / "alpha" / "plugin.py").exists()
|
||||
|
||||
|
||||
def test_short_form_requires_existing_payload_root(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced")
|
||||
reg = ToolRegistry(repo_dir=tmp_path / "repo", drive_root=tmp_path / "drive")
|
||||
(tmp_path / "repo").mkdir()
|
||||
|
||||
result = reg.execute(
|
||||
"str_replace_editor",
|
||||
{
|
||||
"path": "plugin.py",
|
||||
"old_str": "x",
|
||||
"new_str": "y",
|
||||
"bucket": "external",
|
||||
"skill_name": "ghost",
|
||||
},
|
||||
)
|
||||
|
||||
assert "skill payload not found" in result, result[:300]
|
||||
|
||||
|
||||
def test_cross_skill_redirect_error_unit():
|
||||
"""The helper that produces SKILL_REDIRECT_BLOCKED text. Empty string means
|
||||
'no conflict, proceed'; non-empty means 'reject the call'."""
|
||||
|
|
|
|||
42
tests/test_settings_budget_hotreload.py
Normal file
42
tests/test_settings_budget_hotreload.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Settings save budget hot-reload regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_settings_post_updates_supervisor_budget_limits(monkeypatch, tmp_path):
|
||||
import server as srv
|
||||
import supervisor.message_bus as bus_mod
|
||||
import supervisor.state as state_mod
|
||||
|
||||
current = dict(srv._SETTINGS_DEFAULTS)
|
||||
current["TOTAL_BUDGET"] = 10.0
|
||||
monkeypatch.setattr(state_mod, "TOTAL_BUDGET_LIMIT", 10.0)
|
||||
monkeypatch.setattr(bus_mod, "TOTAL_BUDGET_LIMIT", 10.0)
|
||||
|
||||
monkeypatch.setattr(srv, "load_settings", lambda: dict(current))
|
||||
|
||||
def fake_save_settings(settings, *args, **kwargs):
|
||||
current.clear()
|
||||
current.update(settings)
|
||||
|
||||
monkeypatch.setattr(srv, "save_settings", fake_save_settings)
|
||||
monkeypatch.setattr(srv, "_apply_settings_to_env", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(srv, "_start_supervisor_if_needed", lambda *_a, **_k: False)
|
||||
monkeypatch.setattr(srv, "apply_runtime_provider_defaults", lambda s: (dict(s), False, []))
|
||||
monkeypatch.setattr(srv, "_mcp_reconfigure_startup", lambda *_a, **_k: None, raising=False)
|
||||
|
||||
app = Starlette(routes=[Route("/api/settings", endpoint=srv.api_settings_post, methods=["POST"])])
|
||||
app.state.drive_root = tmp_path / "drive"
|
||||
app.state.repo_dir = tmp_path / "repo"
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post("/api/settings", json={"TOTAL_BUDGET": 25.0})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json().get("immediate_changed") is True
|
||||
assert state_mod.TOTAL_BUDGET_LIMIT == 25.0
|
||||
assert bus_mod.TOTAL_BUDGET_LIMIT == 25.0
|
||||
|
|
@ -420,6 +420,40 @@ def test_aggregate_status_blockers_on_bug_hunting_fail(monkeypatch):
|
|||
assert _aggregate_status(findings, skill_type="script") == "blockers"
|
||||
|
||||
|
||||
def test_aggregate_status_warnings_on_advisory_bug_hunting_fail(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
{
|
||||
"item": "bug_hunting",
|
||||
"verdict": "FAIL",
|
||||
"severity": "advisory",
|
||||
"reason": "provider sometimes flakes; improve retry diagnostics later",
|
||||
},
|
||||
]
|
||||
assert _aggregate_status(findings, skill_type="script") == "warnings"
|
||||
|
||||
|
||||
def test_aggregate_status_skill_preflight_stays_hard_critical(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
{"item": "skill_preflight", "verdict": "FAIL", "severity": "advisory", "reason": "syntax error"},
|
||||
]
|
||||
assert _aggregate_status(findings, skill_type="script") == "blockers"
|
||||
|
||||
|
||||
def test_aggregate_status_no_repo_mutation_stays_hard_critical(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
{
|
||||
"item": "no_repo_mutation",
|
||||
"verdict": "FAIL",
|
||||
"severity": "advisory",
|
||||
"reason": "skill writes to ~/Ouroboros/repo",
|
||||
},
|
||||
]
|
||||
assert _aggregate_status(findings, skill_type="script") == "blockers"
|
||||
|
||||
|
||||
def test_aggregate_status_extension_namespace_fail_is_critical_only_for_extension(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
|
|
@ -431,6 +465,19 @@ def test_aggregate_status_extension_namespace_fail_is_critical_only_for_extensio
|
|||
assert _aggregate_status(findings, skill_type="extension") == "blockers"
|
||||
|
||||
|
||||
def test_aggregate_status_extension_namespace_advisory_fail_warns(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
{
|
||||
"item": "extension_namespace_discipline",
|
||||
"verdict": "FAIL",
|
||||
"severity": "advisory",
|
||||
"reason": "minor naming cleanup would improve clarity",
|
||||
},
|
||||
]
|
||||
assert _aggregate_status(findings, skill_type="extension") == "warnings"
|
||||
|
||||
|
||||
def test_aggregate_status_widget_module_safety_fail_is_critical_only_for_module_widgets(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
|
|
@ -441,6 +488,19 @@ def test_aggregate_status_widget_module_safety_fail_is_critical_only_for_module_
|
|||
assert _aggregate_status(findings, skill_type="extension", is_module_widget=True) == "blockers"
|
||||
|
||||
|
||||
def test_aggregate_status_companion_process_advisory_fail_warns(monkeypatch):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
findings = [
|
||||
{
|
||||
"item": "companion_process_safety",
|
||||
"verdict": "FAIL",
|
||||
"severity": "advisory",
|
||||
"reason": "transient subprocess would benefit from clearer logging",
|
||||
},
|
||||
]
|
||||
assert _aggregate_status(findings, skill_type="extension") == "warnings"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# review_skill end-to-end (mocked LLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -144,6 +144,68 @@ def test_claude_code_edit_reverts_normal_skill_sidecars(tmp_path, monkeypatch):
|
|||
assert sidecar.read_text(encoding="utf-8") == "original"
|
||||
|
||||
|
||||
def test_claude_code_edit_omitted_cwd_ignores_stale_short_form(tmp_path, monkeypatch):
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import sys
|
||||
from ouroboros.tools.shell import _claude_code_edit
|
||||
|
||||
gateway = ModuleType("ouroboros.gateways.claude_code")
|
||||
gateway.resolve_claude_code_model = lambda: "test-model"
|
||||
gateway.DEFAULT_CLAUDE_CODE_MAX_TURNS = 1
|
||||
sys.modules["ouroboros.gateways.claude_code"] = gateway
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
drive = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
(drive / "skills" / "external" / "alpha").mkdir(parents=True)
|
||||
ctx = ToolContext(repo_dir=repo, drive_root=drive)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
captured = {}
|
||||
|
||||
def fake_run_edit(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
success=True,
|
||||
error="",
|
||||
result_text="ok",
|
||||
cost_usd=0.0,
|
||||
usage={},
|
||||
changed_files=[],
|
||||
diff_stat="",
|
||||
validation_summary="",
|
||||
to_tool_output=lambda: "OK",
|
||||
)
|
||||
|
||||
gateway.run_edit = fake_run_edit
|
||||
|
||||
result = _claude_code_edit(
|
||||
ctx,
|
||||
"edit repo",
|
||||
bucket="external",
|
||||
skill_name="alpha",
|
||||
)
|
||||
|
||||
assert "SKILL_SHORT_FORM_IGNORED" in result
|
||||
assert captured["cwd"] == str(repo)
|
||||
|
||||
|
||||
def test_claude_code_edit_rejects_non_skill_data_cwd(tmp_path, monkeypatch):
|
||||
from ouroboros.tools.shell import _claude_code_edit
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
drive = tmp_path / "data"
|
||||
repo.mkdir()
|
||||
drive.mkdir()
|
||||
(drive / "settings.json").write_text('{"TOTAL_BUDGET": 10}\n', encoding="utf-8")
|
||||
ctx = ToolContext(repo_dir=repo, drive_root=drive)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
result = _claude_code_edit(ctx, "edit settings", cwd=str(drive))
|
||||
|
||||
assert "CLAUDE_CODE_ERROR" in result
|
||||
assert "non-skill data cwd is not allowed" in result
|
||||
|
||||
|
||||
def test_repair_data_write_manifest_does_not_create_self_authored_markers(tmp_path, monkeypatch):
|
||||
from ouroboros import config as cfg
|
||||
ctx, skill = _ctx(tmp_path)
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ def test_widgets_cards_do_not_stretch_to_row_height():
|
|||
assert "widgets-card-span-2" in source
|
||||
assert "applyMasonry(list)" in source
|
||||
assert "function layout(container, config)" in masonry
|
||||
assert "itemResizeObserver" in masonry
|
||||
assert "observeItems()" in masonry
|
||||
widgets_block = css.split(".widgets-list {", 1)[1].split("}", 1)[0]
|
||||
assert "display: grid" not in widgets_block
|
||||
assert "position: relative;" in widgets_block
|
||||
|
|
@ -174,3 +176,14 @@ def test_widgets_v5_7_0_new_components_render():
|
|||
# extension JS is acceptable in any of the new component renderers.
|
||||
assert "data-widget-map-config" in source
|
||||
assert "widget-kanban-card" in source
|
||||
|
||||
|
||||
def test_widgets_render_subscription_children():
|
||||
source = _widgets_js()
|
||||
assert "type === 'subscription'" in source
|
||||
assert "component.render" in source
|
||||
assert "widget-subscription-render" in source
|
||||
assert "value_key" in source
|
||||
assert "items_key" in source
|
||||
assert "route_prefix" in source
|
||||
assert "type === 'key_value'" in source
|
||||
|
|
|
|||
|
|
@ -90,9 +90,10 @@ export function initCosts({ ws, state, mount = null, embedded = false, hostPage
|
|||
tdCost.className = 'cost-cell-right';
|
||||
tdCost.textContent = formatUsd2(info.cost);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
const bar = document.createElement('progress');
|
||||
bar.className = 'cost-bar';
|
||||
bar.style.width = Math.min(100, pct) + '%';
|
||||
bar.max = 100;
|
||||
bar.value = Math.min(100, pct);
|
||||
|
||||
const tdBar = document.createElement('td');
|
||||
tdBar.className = 'cost-bar-cell';
|
||||
|
|
@ -131,7 +132,7 @@ export function initCosts({ ws, state, mount = null, embedded = false, hostPage
|
|||
try {
|
||||
const resp = await fetch('/api/settings', { cache: 'no-store' });
|
||||
const s = await resp.json().catch(() => ({}));
|
||||
if (s.TOTAL_BUDGET) document.getElementById('s-budget').value = s.TOTAL_BUDGET;
|
||||
if (s.TOTAL_BUDGET != null) document.getElementById('s-budget').value = s.TOTAL_BUDGET;
|
||||
if (s.OUROBOROS_PER_TASK_COST_USD != null) document.getElementById('s-per-task-cost').value = s.OUROBOROS_PER_TASK_COST_USD;
|
||||
} catch {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,9 +84,27 @@ export function applyMasonry(container, options = {}) {
|
|||
const run = () => requestAnimationFrame(() => layout(container, config));
|
||||
run();
|
||||
if (bound.has(container)) return;
|
||||
const observedItems = new Set();
|
||||
const itemResizeObserver = new ResizeObserver(run);
|
||||
const observeItems = () => {
|
||||
Array.from(observedItems).forEach((item) => {
|
||||
if (container.contains(item)) return;
|
||||
itemResizeObserver.unobserve(item);
|
||||
observedItems.delete(item);
|
||||
});
|
||||
container.querySelectorAll(config.itemSelector).forEach((item) => {
|
||||
if (observedItems.has(item)) return;
|
||||
observedItems.add(item);
|
||||
itemResizeObserver.observe(item);
|
||||
});
|
||||
};
|
||||
observeItems();
|
||||
const resizeObserver = new ResizeObserver(run);
|
||||
resizeObserver.observe(container);
|
||||
const mutationObserver = new MutationObserver(run);
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
observeItems();
|
||||
run();
|
||||
});
|
||||
mutationObserver.observe(container, { childList: true, subtree: true });
|
||||
bound.set(container, { resizeObserver, mutationObserver });
|
||||
bound.set(container, { resizeObserver, itemResizeObserver, mutationObserver });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,19 @@ function safeMediaSrc(tab, spec, state) {
|
|||
return '';
|
||||
}
|
||||
|
||||
function routePrefixToMediaSpec(routePrefix, value, itemType = 'image') {
|
||||
const text = String(value || '').trim();
|
||||
const prefix = String(routePrefix || '').trim();
|
||||
if (!prefix || !text) return { type: itemType, src: text };
|
||||
const [route, queryKey = 'path'] = prefix.split('?', 2);
|
||||
const key = queryKey.endsWith('=') ? queryKey.slice(0, -1) : queryKey;
|
||||
return {
|
||||
type: itemType,
|
||||
route,
|
||||
query: { [key || 'path']: text },
|
||||
};
|
||||
}
|
||||
|
||||
function filenameFromWidgetUrl(url, fallback = 'download') {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
|
|
@ -186,6 +199,9 @@ function renderDataComponent(tab, component, state, status, componentState = {},
|
|||
const type = String(component.type || '');
|
||||
const target = component.target || 'result';
|
||||
const data = state[target] || {};
|
||||
if (component.condition_key && !getPath(data, component.condition_key, false)) {
|
||||
return '';
|
||||
}
|
||||
if (type === 'status') {
|
||||
const current = status[target] || 'idle';
|
||||
return `<div class="widget-status" data-state="${escapeHtml(current)}">${escapeHtml(component[current] || current)}</div>`;
|
||||
|
|
@ -199,6 +215,11 @@ function renderDataComponent(tab, component, state, status, componentState = {},
|
|||
}).join('');
|
||||
return `<div class="widget-kv">${rows || '<div class="muted">No data.</div>'}</div>`;
|
||||
}
|
||||
if (type === 'key_value') {
|
||||
const rows = getPath(data, component.items_key || component.path || '', []);
|
||||
if (!Array.isArray(rows) || !rows.length) return '';
|
||||
return `<div class="widget-kv">${rows.map((row) => `<div class="widget-kv-row"><span>${escapeHtml(row?.key || row?.label || '')}</span><strong>${escapeHtml(row?.value ?? '')}</strong></div>`).join('')}</div>`;
|
||||
}
|
||||
if (type === 'table') {
|
||||
const rows = getPath(data, component.path || '', []);
|
||||
const cols = component.columns || [];
|
||||
|
|
@ -250,14 +271,22 @@ function renderDataComponent(tab, component, state, status, componentState = {},
|
|||
return `<button class="btn btn-default widget-download" type="button" data-widget-download-url="${escapeHtml(src)}" data-widget-download-filename="${filename}">${label}</button>`;
|
||||
}
|
||||
if (type === 'gallery') {
|
||||
const items = component.items || getPath(data, component.path || '', []);
|
||||
let items = component.items || getPath(data, component.path || component.items_key || '', []);
|
||||
if (!Array.isArray(items)) return '<div class="muted">No media items.</div>';
|
||||
if (component.items_key && component.route_prefix) {
|
||||
items = items.map((item) => routePrefixToMediaSpec(
|
||||
component.route_prefix,
|
||||
typeof item === 'object' ? (item.path || item.src || item.url || '') : item,
|
||||
component.item_type || 'image',
|
||||
));
|
||||
}
|
||||
return `<div class="widget-gallery">${items.map((item, idx) => renderDataComponent(tab, { ...item, type: item.type || 'image' }, state, status, componentState, `${componentKey}:gallery:${idx}`)).join('')}</div>`;
|
||||
}
|
||||
if (type === 'progress') {
|
||||
const value = Number(getPath(data, component.path || 'progress', 0));
|
||||
const value = Number(getPath(data, component.path || component.value_key || 'progress', 0));
|
||||
const bounded = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
|
||||
return `<div class="widget-progress"><progress max="100" value="${bounded}"></progress><span>${bounded}%</span></div>`;
|
||||
const label = component.label_key ? getPath(data, component.label_key, '') : '';
|
||||
return `<div class="widget-progress"><progress max="100" value="${bounded}"></progress><span>${bounded}%${label ? ` · ${escapeHtml(label)}` : ''}</span></div>`;
|
||||
}
|
||||
// v5.7.0: host-owned ``map`` renderer. Falls back to a flat marker
|
||||
// list when Leaflet is not available; when Leaflet is loaded by the
|
||||
|
|
@ -299,6 +328,18 @@ function renderDataComponent(tab, component, state, status, componentState = {},
|
|||
}).join('');
|
||||
return `<div class="widget-kanban" data-widget-kanban-idx="${escapeHtml(componentKey)}" data-widget-kanban-route="${escapeHtml(moveRoute || '')}">${colHtml}</div>`;
|
||||
}
|
||||
if (type === 'subscription') {
|
||||
const children = Array.isArray(component.render) ? component.render : [];
|
||||
if (!children.length) return '';
|
||||
return `<div class="widget-subscription-render">${children.map((child, idx) => {
|
||||
if (!child || typeof child !== 'object') return '';
|
||||
const normalized = {
|
||||
...child,
|
||||
target: child.target || target,
|
||||
};
|
||||
return renderDataComponent(tab, normalized, state, status, componentState, `${componentKey}:subscription:${idx}`);
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -552,9 +593,6 @@ async function mountDeclarativeWidget(mount, tab, render) {
|
|||
if (type === 'poll') {
|
||||
return `<button class="btn btn-default" data-widget-poll="${idx}">${escapeHtml(component.label || 'Start polling')}</button>`;
|
||||
}
|
||||
if (type === 'subscription') {
|
||||
return '';
|
||||
}
|
||||
return renderDataComponent(tab, component, state, status, componentState, String(idx));
|
||||
}).join('');
|
||||
mount.querySelectorAll('[data-widget-form]').forEach((form) => {
|
||||
|
|
|
|||
|
|
@ -2807,7 +2807,10 @@ body {
|
|||
.cost-cell-right { text-align: right; }
|
||||
.cost-top-model { font-size: 12px; }
|
||||
.cost-bar-cell { width: 60px; }
|
||||
.cost-bar { background: var(--accent); height: 6px; border-radius: var(--radius-xs); opacity: 0.7; }
|
||||
.cost-bar { width: 60px; height: 6px; border: 0; border-radius: var(--radius-xs); overflow: hidden; opacity: 0.7; }
|
||||
.cost-bar::-webkit-progress-bar { background: rgba(255, 255, 255, 0.08); }
|
||||
.cost-bar::-webkit-progress-value { background: var(--accent); }
|
||||
.cost-bar::-moz-progress-bar { background: var(--accent); }
|
||||
.cost-empty-cell { color: var(--text-muted); text-align: center; }
|
||||
|
||||
/* Updates panel */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue