From 69624084fd7b4111aed09348fad5f9b85bb84e16 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 3 Aug 2026 12:37:00 +0000 Subject: [PATCH 1/2] fix: stop asserting one install's stack in managed guidance (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed wordpress-source section is generated into the AGENTS.md of every managed install, but it described h44lacrosse.com's stack as if it were universal: - "including commerce and payment code" and "the site's ability to take money" — there may be no store and no gateway - "the runtime that gives you memory and tools" — that is Data Machine, this install's happenstance, not a property of wp-content/plugins - a specific never-captured list (vendor, node_modules, composer.lock, package-lock.json, package.json) — one operator's harvest excludes copied verbatim, from a config this tool does not own and cannot read - "exactly what the operator's capture records" — the intended invariant of the posture, which the tool has no way to verify This is the #314/#318 failure in a third form. Those were prose claiming things the enforced policy could not back; this is prose claiming things about the environment that the tool never inspected. An agent told that editing a plugin breaks the site's ability to take money, on a site with no commerce plugin, has been handed a false model of its own install by the file that is supposed to be its most reliable source of truth. Presenting a guessed exclude list is worse than silence, because the agent will trust it. Now describes the category and the reason and leaves the specifics to the operator: plugins this site did not author, including whatever this agent depends on; an update overwrites the change and nothing captures it, both true regardless of what any plugin does; installed and generated files are not authored source, so ask the operator which paths this site actually captures rather than assume. tests/posture.sh gains a guard asserting the rendered prose names none of WooCommerce, Stripe, commerce, payment, money, composer.lock, package-lock, node_modules, Data Machine, homeboy, harvest.yml, or wp-admin. Verified non-vacuous: reintroducing a single product name fails the suite. The two rules this file must follow are now recorded at the top of it. --- guidance/wordpress-source.managed.sh | 40 ++++++++++++++++++---------- tests/posture.sh | 15 +++++++++-- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/guidance/wordpress-source.managed.sh b/guidance/wordpress-source.managed.sh index 45ffca9..8e039a5 100644 --- a/guidance/wordpress-source.managed.sh +++ b/guidance/wordpress-source.managed.sh @@ -5,8 +5,22 @@ # place at the owner's request. There is no workspace, no git, and no GitHub in # its world. # -# The editable set is enumerated from source_policy_owned_sources, never -# described as a directory. An earlier version of this file said "this site's +# TWO RULES FOR THIS FILE. +# +# 1. Enumerate, never generalise. The editable set comes from +# source_policy_owned_sources and is printed path by path. An earlier version +# said "this site's theme and plugins" while the policy opened +# `wp-content/plugins/**` wholesale — see #318. +# +# 2. Assert only what wp-coding-agents actually knows. This text ships to EVERY +# managed install, so it must not describe one site's stack as if it were +# universal. An earlier version named "commerce and payment code" and "the +# site's ability to take money" (there may be no store), called the remaining +# plugins "the runtime that gives you memory and tools" (that is this +# install's happenstance), and listed a specific set of never-captured paths +# copied from one operator's harvest excludes — a config this tool does not +# own and cannot read. Describe the CATEGORY and the REASON; leave the +# specifics to the operator. An earlier version of this file said "this site's # theme and plugins" while the permission layer opened `wp-content/plugins/**` # wholesale — which on a real install meant WooCommerce, a payment gateway, and # the agent's own Data Machine runtime. Prose that generalises where the policy @@ -48,28 +62,26 @@ guidance_render() { printf '\n' printf '%s\n' '### Read-only — everything else' printf '\n' - printf '%s\n' 'Read these to verify APIs, hooks, conventions, and runtime behavior. Never edit them:' + printf '%s\n' 'Everything else installed here belongs to somebody else. Read it freely to verify APIs, hooks, conventions, and runtime behavior; never edit it:' printf '\n' printf '%s\n' '- `wp-includes/` — WordPress core.' - printf '%s\n' '- The rest of `wp-content/plugins/` — third-party plugins, including commerce and payment code, and the runtime that gives you memory and tools. Editing these breaks your own environment or the site'"'"'s ability to take money, and an update erases the change anyway.' - printf '%s\n' '- The rest of `wp-content/themes/` — bundled and third-party themes.' + printf '%s\n' '- The rest of `wp-content/plugins/` — plugins this site did not author, including whatever this agent itself depends on.' + printf '%s\n' '- The rest of `wp-content/themes/` — themes this site did not author.' printf '\n' - printf '%s\n' 'To change behavior that lives in code you may not edit, change the site'"'"'s own theme or plugin instead — a hook, a filter, or a template override. If that is genuinely impossible, say so rather than editing outside the list.' + printf '%s\n' 'Two reasons, both independent of what any particular plugin does: an update overwrites your change, and nothing captures it, so the work is lost either way.' + printf '\n' + printf '%s\n' 'To change behavior that lives in code you may not edit, change this site'"'"'s own theme or plugin instead — a hook, a filter, or a template override. If that is genuinely impossible, say so rather than editing outside the list.' printf '\n' printf '%s\n' '### Working on production' printf '\n' printf '%s\n' '- **Your edits are live the moment you save them.** There is no staging environment, no review gate, and no deploy step between you and the public site. A syntax error is a down site.' printf '%s\n' '- **Verify before you leave a change in place.** Load the affected page or run the relevant WP-CLI command. You are the only check that runs before visitors see it.' - printf '%s\n' '- **Work is unbacked until it is captured.** The editable list above is exactly what the operator'"'"'s out-of-band capture records, and each capture is a restore point. Between your edit and the next capture, the live file is the only copy.' + printf '%s\n' '- **Work is unbacked until it is captured.** The editable list above is what the operator declared as this site'"'"'s own source, and it is captured out-of-band on a schedule the operator owns. Between your edit and the next capture, the live file is the only copy.' printf '%s\n' '- **Rollback is an operator action.** You cannot restore a previous capture yourself. If something breaks and you cannot fix it forward, say so plainly and immediately rather than continuing to edit.' printf '\n' - printf '%s\n' '### Changes that are never captured' - printf '\n' - printf '%s\n' 'Dependency trees, lockfiles, and build output are installed or generated rather than authored, so a capture skips them even inside the editable list. Editing them produces a change that is **never recorded and is destroyed by the next deploy**:' + printf '%s\n' '### Installed and generated files are not source' printf '\n' - printf '%s\n' '- `vendor/` and `node_modules/` — installed dependency trees' - printf '%s\n' '- `composer.lock`, `package-lock.json`, `package.json` — dependency manifests' - printf '%s\n' '- generated build output' + printf '%s\n' 'Even inside the editable list, not every file is authored source. Dependency trees, lockfiles, and build output are installed or generated from something else, and a capture will usually skip them — so editing them in place is work that quietly disappears.' printf '\n' - printf '%s\n' 'If a task genuinely requires changing one of these, stop and tell the operator. It needs to happen in the source repository, not here.' + printf '%s\n' 'If a task seems to require changing one, stop and ask the operator which paths this site actually captures. Do not assume. The answer belongs to the operator'"'"'s capture configuration, not to you.' } diff --git a/tests/posture.sh b/tests/posture.sh index 0d20b67..cc7a0f3 100755 --- a/tests/posture.sh +++ b/tests/posture.sh @@ -310,13 +310,24 @@ assert_contains "$MGD_PROSE" '`wp-includes/` ' \ "managed prose keeps core read-only" assert_contains "$MGD_PROSE" 'live the moment you save' \ "managed prose states that edits reach production immediately" -assert_contains "$MGD_PROSE" 'never recorded' \ - "managed prose warns about paths a capture silently skips" +assert_contains "$MGD_PROSE" 'not every file is authored source' \ + "managed prose warns that installed and generated files are not captured" assert_contains "$MGD_PROSE" 'no pull request step' \ "managed prose rules out the review workflow rather than leaving it implied" refute_contains "$MGD_PROSE" "Make code changes in the configured managed workspace" \ "managed prose never routes work to a workspace that does not exist" +# This text ships to EVERY managed install, so it must not describe one site's +# stack as if it were universal. #320: it named commerce and payment code, "the +# site's ability to take money", called the remaining plugins "the runtime that +# gives you memory and tools", and listed one operator's harvest excludes +# verbatim — a config wp-coding-agents does not own and cannot read. Assert the +# CATEGORY and the REASON; never the specifics. +for term in WooCommerce Stripe commerce payment money composer.lock package-lock node_modules "Data Machine" homeboy harvest.yml wp-admin; do + refute_contains "$MGD_PROSE" "$term" \ + "managed prose does not assume '$term' exists on this install" +done + # Fail closed in prose too: nothing declared must not read as "edit anything". MANAGED_SOURCES="" NONE_PROSE="$(guidance_call wordpress-source render)" From b49f8bb9611ccfc6a1c3ae87ad1158c487f2f374 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 3 Aug 2026 13:03:07 +0000 Subject: [PATCH 2/2] fix: restore the wordpress-source section's purpose and close the core gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #322. Supersedes #320, which fixed false claims inside a shape that was itself wrong. PURPOSE. The section exists so the agent is an expert on its own runtime by reading the WordPress installed underneath it — that is what lets a small model be competent about WordPress with no skills and no fine-tuning. Direct Reference was the point; Read-Only was a qualifier. The managed variant added in #315 inverted that into an editable list, a read-only list, a justification, a production contract, and a "not source" appendix, with the reference clause buried in the middle. The restriction half was also redundant. lib/source-policy.sh makes the runtime enforce it; prose enforces nothing. Restating the deny list bought no safety while crowding out the one thing prose is uniquely good at. Both variants now lead with what the source is FOR and name the boundary only so the agent is not misinformed about it. The managed section is roughly half its previous length. CORE WAS HALF-DEFINED. wp-admin and wp-includes are siblings, not nested. The root set was wp-content/plugins, wp-content/themes, wp-includes — so on every install ever created the agent could edit wp-admin and every root PHP file, including wp-config.php, which holds the database credentials and the constants that gate AGENTS.md composition. As reference the same omission pointed the agent away from list tables, media, and the upgrade routines. Both halves fixed by defining core once. wp-content/mu-plugins is now denied too. It is agent governance, not a site extension: wp-coding-agents installs the mu-plugin that GENERATES AGENTS.md there. wp-content/uploads stays open because the agent's memory lives under it. DECLARED EXCEPTIONS. wp-config.php has real uses (WP_DEBUG, memory limits), so --managed-writable re-opens a denied path. It cannot reuse --managed-source: that flag means editable AND captured, which is what makes the "your work is recorded" promise true, and wp-config.php is not captured by a component harvest. Conflating them would assert a safety property that does not hold — the #318 failure. Separate category, separate prose. LOGS. The limitation was backwards. We were strict about editing core (correct; an update overwrites it anyway) and accidentally strict about READING the one thing needed to recover from a fatal. OpenCode gates paths outside the site root behind external_directory, which defaults to ask, and an autonomous agent has nobody to ask — so the most important recovery capability in the design sat behind an unanswerable prompt. --log-path grants read and explicitly denies edit. GLOB TRAP. Root files are exact literals, not globs. OpenCode's matcher turns * into .* which spans slashes, so wp-*.php would also match wp-content/plugins/acme/wp-thing.php and over-deny inside a site's own component. source_policy_edit_rules now emits a dir/file kind so each runtime can format correctly. tests/posture.sh pins the purpose (reference framing present in both variants), the enumerate-never-generalise rule, the writable-is-not-captured distinction, log paths readable but not writable, uploads untouched, ordering, and the #320 guard against naming one install's stack. --- README.md | 62 ++++-- guidance/wordpress-source.engineering.sh | 25 ++- guidance/wordpress-source.managed.sh | 117 ++++++----- lib/repair-opencode-json.py | 97 +++++++-- lib/source-policy.sh | 241 ++++++++++++++++++++++- runtimes/claude-code.sh | 7 +- runtimes/codex.sh | 4 +- runtimes/opencode.sh | 37 +++- setup.sh | 18 ++ tests/opencode-local-plugin-path.sh | 28 +-- tests/posture.sh | 236 ++++++++++++++-------- tests/repair-opencode-json.sh | 22 ++- upgrade.sh | 10 + 13 files changed, 680 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index e0599e4..43add08 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ operator-entrypoints/wp-coding-agents-setup/setup.md | `--runtime ` | Coding runtime: `opencode`, `claude-code`, or `codex`. Auto-detected when omitted. | | `--posture ` | `engineering` (default) or `managed`. See [Install Posture](#install-posture). | | `--managed-source ` | wp-content path the site owns and may edit under `--posture managed`. Repeatable. | +| `--managed-writable ` | Denied path to re-open for editing (e.g. `wp-config.php`). Not captured. Repeatable. | +| `--log-path ` | Absolute path outside the site root the agent may read. Repeatable. | | `--local` | Local machine mode. Skips server infrastructure. | | `--existing` | Add to an existing WordPress install. | | `--wp-path ` | WordPress root path. Implies `--existing`. | @@ -192,13 +194,20 @@ guidance the agent reads. | | `engineering` (default) | `managed` | | --- | --- | --- | -| `wp-content/themes/`, `wp-content/plugins/` | read-only reference | read-only **except declared owned paths** | -| `wp-includes/` | read-only | read-only | +| WordPress core (`wp-admin/`, `wp-includes/`, root bootstrap) | read-only | read-only | +| `wp-content/mu-plugins/`, `wp-config.php` | read-only | read-only, opt-in | +| `wp-content/plugins/`, `wp-content/themes/` | read-only | read-only **except declared owned paths** | +| `wp-content/uploads/` | writable (agent memory) | writable (agent memory) | | Data Machine Code | installed | not installed | | Workspace, git, GitHub | the agent's workflow | not present | | How changes reach version control | the agent commits and opens pull requests | captured out-of-band by the operator | | Runtimes | all | `opencode` only | +Both postures point the agent at the installed WordPress source as reference — +that is the section's purpose, and it is why a small model can be competent +about WordPress here without skills or fine-tuning. Reading is never +restricted. Everything below is about *writing*. + **Engineering** is the developer setup: the installed tree is reference material, and every code change happens in a Data Machine Code workspace so it is tracked in git and reviewed through GitHub. @@ -209,11 +218,9 @@ plugins directly and its changes are live on save; something outside the box — for example a scheduled `homeboy harvest` — captures them into git as restore points. -Managed does **not** open `wp-content`. Those directories also hold commerce and -payment plugins, third-party code, the stock themes, and the agent's own Data -Machine runtime — none of which the site owns, none of which the operator's -capture records, and all of which an update would overwrite anyway. Instead you -declare exactly what the site owns: +Managed does **not** open `wp-content`. Those directories hold third-party +code, the stock themes, and the agent's own runtime, none of which the site +owns or the operator's capture records. Declare exactly what the site owns: ```bash ./setup.sh --posture managed \ @@ -221,16 +228,41 @@ declare exactly what the site owns: --managed-source wp-content/plugins/acme-core ``` -Everything else stays read-only. **The declared set must match what the -operator's capture records** — a path the agent can edit but nothing records is -a path where it silently loses work. Declaring nothing fails closed: the agent -gets no editable source and the generated guidance says so. +**The declared set must match what the operator's capture records** — a path +the agent can edit but nothing records is a path where it silently loses work. +Declaring nothing fails closed: no editable source, and the guidance says so. + +### Declared exceptions + +`wp-config.php` and `wp-content/mu-plugins/` are agent governance — they hold +the database credentials and the mu-plugin that generates AGENTS.md itself, so +an agent able to edit them can rewrite its own instructions. They are denied by +default and re-opened only on request: + +```bash +--managed-writable wp-config.php +``` + +These are **editable but not captured**, and the generated guidance says so +explicitly. Conflating them with `--managed-source` would have AGENTS.md +promise that the work is recorded when it is not. + +### Log access + +A coding agent that cannot read the PHP error log cannot recover the site it +just broke. OpenCode gates paths outside the site root behind +`external_directory`, which defaults to `ask` — and an autonomous agent has +nobody to ask. Declare the host's log paths so they are readable (never +writable): + +```bash +--log-path /var/log/nginx --log-path /var/log/php8.4-fpm.log +``` A single source of truth (`lib/source-policy.sh`) derives the runtime -permissions and the generated guidance from the chosen posture and that -declared set, so the prose cannot tell the agent to do something the -permissions then block — or imply a directory is editable when only two paths -inside it are. +permissions and the generated guidance from the posture and these +declarations, so the prose cannot tell the agent to do something the +permissions then block. Managed is currently supported on the `opencode` runtime only. OpenCode evaluates permissions with `findLast` over rules in config key order, so a diff --git a/guidance/wordpress-source.engineering.sh b/guidance/wordpress-source.engineering.sh index 99da5d7..7de75c3 100644 --- a/guidance/wordpress-source.engineering.sh +++ b/guidance/wordpress-source.engineering.sh @@ -1,15 +1,18 @@ #!/bin/bash # guidance/wordpress-source.engineering.sh — installed WordPress source, engineering posture. # -# The agent treats the installed tree as reference material and makes every code -# change in a Data Machine Code workspace, tracked in git and reviewed through -# GitHub. Matches the deny rules lib/source-policy.sh hands the runtimes for this -# posture: all three roots read-only, workspace granted. +# Same purpose as the managed variant: CAPABILITY. The agent should be an expert +# on its own runtime by reading the WordPress actually installed here rather +# than relying on training data. The read-only boundary is a qualifier, not the +# point — see the header of wordpress-source.managed.sh and #322. +# +# What differs from managed is only where changes land: engineering routes them +# through a Data Machine Code workspace so they are tracked in git and reviewed. guidance_id() { printf 'wordpress-source'; } guidance_priority() { printf '1'; } guidance_label() { printf 'WordPress Source'; } -guidance_description() { printf 'Direct-reference and read-only boundaries for installed WordPress source.'; } +guidance_description() { printf 'Points the agent at the installed WordPress source as read-only reference.'; } guidance_freshness() { printf 'static'; } guidance_conditions() { printf 'Registered by wp-coding-agents on engineering-posture installations.'; } @@ -17,12 +20,14 @@ guidance_render() { cat <<'MD' ## WordPress Source (Direct Reference, Read-Only) -Use the installed WordPress source to verify core APIs, hooks, conventions, and runtime behavior instead of relying on assumptions. Search and read these directories directly when working on WordPress: +The WordPress running this site is on disk underneath you. Read it to verify core APIs, hooks, conventions, and runtime behavior instead of relying on assumptions: + +- `wp-includes/` — core internals: the hook system, query, HTTP, database, and template APIs. +- `wp-admin/` — the other half of core: admin screens, list tables, media and upgrade routines. +- `wp-content/plugins/` and `wp-content/themes/` — every extension installed here. -- `wp-content/plugins/` — plugin source (read-only) -- `wp-content/themes/` — theme source (read-only) -- `wp-includes/` — WordPress core (read-only) +Grep and read these freely. They are the ground truth for how this site actually behaves. -These paths are **read-only references**. Make code changes in the configured managed workspace, not in the installed source tree. +These paths are **read-only reference**. Make code changes in the configured managed workspace, not in the installed source tree. MD } diff --git a/guidance/wordpress-source.managed.sh b/guidance/wordpress-source.managed.sh index 8e039a5..3515b6d 100644 --- a/guidance/wordpress-source.managed.sh +++ b/guidance/wordpress-source.managed.sh @@ -1,87 +1,86 @@ #!/bin/bash # guidance/wordpress-source.managed.sh — installed WordPress source, managed posture. # -# Managed agentic hosting: the agent edits the site's OWN theme and plugins in -# place at the owner's request. There is no workspace, no git, and no GitHub in -# its world. +# WHAT THIS SECTION IS FOR # -# TWO RULES FOR THIS FILE. +# Capability, not restriction. It exists so the agent stops guessing at +# WordPress and reads the source actually running underneath it — that is what +# lets a small model be competent about WordPress with no skills and no +# fine-tuning. Reference is the point; the ownership boundary is a qualifier. +# +# Restriction is the permission layer's job and is already enforced there +# (lib/source-policy.sh). Prose enforces nothing, so restating the deny list +# here buys no safety while crowding out the one thing prose is uniquely good +# at. The boundary appears only so the agent is not MISINFORMED about what it +# may change — a far smaller job than policing it. See #322. +# +# TWO RULES WHEN EDITING THIS FILE # # 1. Enumerate, never generalise. The editable set comes from -# source_policy_owned_sources and is printed path by path. An earlier version -# said "this site's theme and plugins" while the policy opened -# `wp-content/plugins/**` wholesale — see #318. +# source_policy_owned_sources and is printed path by path. An earlier +# version said "this site's theme and plugins" while the policy opened +# `wp-content/plugins/**` wholesale — #318. # # 2. Assert only what wp-coding-agents actually knows. This text ships to EVERY # managed install, so it must not describe one site's stack as if it were # universal. An earlier version named "commerce and payment code" and "the -# site's ability to take money" (there may be no store), called the remaining -# plugins "the runtime that gives you memory and tools" (that is this -# install's happenstance), and listed a specific set of never-captured paths -# copied from one operator's harvest excludes — a config this tool does not -# own and cannot read. Describe the CATEGORY and the REASON; leave the -# specifics to the operator. An earlier version of this file said "this site's -# theme and plugins" while the permission layer opened `wp-content/plugins/**` -# wholesale — which on a real install meant WooCommerce, a payment gateway, and -# the agent's own Data Machine runtime. Prose that generalises where the policy -# enumerates is how that gap opens. If a path is not in the declared set it is -# not editable, and this section must say so by name. +# site's ability to take money" (there may be no store), and listed one +# operator's harvest excludes verbatim — a config this tool cannot read. +# Describe the CATEGORY and the REASON; leave the specifics to the +# operator. #320. guidance_id() { printf 'wordpress-source'; } guidance_priority() { printf '1'; } guidance_label() { printf 'WordPress Source'; } -guidance_description() { printf 'Editable owned source, read-only everything else, and the live-production contract.'; } +guidance_description() { printf 'Points the agent at the installed WordPress source and names the trees this site owns.'; } guidance_freshness() { printf 'conditional'; } -guidance_conditions() { printf 'Registered on managed-posture installations; the editable path list is generated from the declared managed sources.'; } +guidance_conditions() { printf 'Registered on managed-posture installations; the owned-source list is generated from the declared managed sources.'; } guidance_render() { - local owned + local owned writable owned="$(source_policy_owned_sources)" + writable="$(source_policy_writable_paths)" printf '%s\n' '## WordPress Source' printf '\n' + printf '%s\n' 'The WordPress running this site is on disk underneath you. Read it to verify core APIs, hooks, conventions, and runtime behavior instead of relying on assumptions:' + printf '\n' + printf '%s\n' '- `wp-includes/` — core internals: the hook system, query, HTTP, database, and template APIs.' + printf '%s\n' '- `wp-admin/` — the other half of core: admin screens, list tables, media and upgrade routines.' + printf '%s\n' '- `wp-content/plugins/` and `wp-content/themes/` — every extension installed here, including the ones this site depends on.' + printf '\n' + printf '%s\n' 'Grep and read these freely. They are the ground truth for how this site actually behaves.' + printf '\n' - if [ -z "$owned" ]; then - # Fail closed, loudly. Never imply a directory is editable. - printf '%s\n' 'This install declares no editable source. Every file under `wp-content/` and `wp-includes/` is **read-only** to you.' - printf '\n' - printf '%s\n' 'Read them freely to verify APIs, hooks, and runtime behavior. If a task requires changing code, stop and tell the operator that no editable source is configured.' + if [ -z "$owned" ] && [ -z "$writable" ]; then + printf '%s\n' 'Nothing on this install is declared as editable, so treat all of it as reference. If a task requires changing code, say that no editable source is configured rather than picking somewhere to edit.' return 0 fi - printf '%s\n' 'You edit this site directly. There is no separate checkout, no workspace, and no pull request step.' - printf '\n' - printf '%s\n' '### Editable — this is the complete list' - printf '\n' - printf '%s\n' 'These are the source trees this site owns. Nothing else is editable, no matter where it lives:' - printf '\n' - printf '%s\n' "$owned" | while IFS= read -r path; do - [ -n "$path" ] || continue - printf -- '- `%s/`\n' "$path" - done - printf '\n' - printf '%s\n' '### Read-only — everything else' - printf '\n' - printf '%s\n' 'Everything else installed here belongs to somebody else. Read it freely to verify APIs, hooks, conventions, and runtime behavior; never edit it:' - printf '\n' - printf '%s\n' '- `wp-includes/` — WordPress core.' - printf '%s\n' '- The rest of `wp-content/plugins/` — plugins this site did not author, including whatever this agent itself depends on.' - printf '%s\n' '- The rest of `wp-content/themes/` — themes this site did not author.' - printf '\n' - printf '%s\n' 'Two reasons, both independent of what any particular plugin does: an update overwrites your change, and nothing captures it, so the work is lost either way.' + printf '%s\n' '### What is yours to change' printf '\n' - printf '%s\n' 'To change behavior that lives in code you may not edit, change this site'"'"'s own theme or plugin instead — a hook, a filter, or a template override. If that is genuinely impossible, say so rather than editing outside the list.' - printf '\n' - printf '%s\n' '### Working on production' - printf '\n' - printf '%s\n' '- **Your edits are live the moment you save them.** There is no staging environment, no review gate, and no deploy step between you and the public site. A syntax error is a down site.' - printf '%s\n' '- **Verify before you leave a change in place.** Load the affected page or run the relevant WP-CLI command. You are the only check that runs before visitors see it.' - printf '%s\n' '- **Work is unbacked until it is captured.** The editable list above is what the operator declared as this site'"'"'s own source, and it is captured out-of-band on a schedule the operator owns. Between your edit and the next capture, the live file is the only copy.' - printf '%s\n' '- **Rollback is an operator action.** You cannot restore a previous capture yourself. If something breaks and you cannot fix it forward, say so plainly and immediately rather than continuing to edit.' - printf '\n' - printf '%s\n' '### Installed and generated files are not source' - printf '\n' - printf '%s\n' 'Even inside the editable list, not every file is authored source. Dependency trees, lockfiles, and build output are installed or generated from something else, and a capture will usually skip them — so editing them in place is work that quietly disappears.' + + if [ -n "$owned" ]; then + printf '%s\n' 'This site owns these, and they are the complete list. You edit them in place — there is no checkout, no workspace, and no pull request step:' + printf '\n' + printf '%s\n' "$owned" | while IFS= read -r path; do + [ -n "$path" ] || continue + printf -- '- `%s/`\n' "$path" + done + printf '\n' + fi + + if [ -n "$writable" ]; then + printf '%s\n' 'You may also change these, but **nothing captures them** — a rebuild or a migration will not carry the change, so make it only when asked and tell the operator you did:' + printf '\n' + printf '%s\n' "$writable" | while IFS= read -r path; do + [ -n "$path" ] || continue + printf -- '- `%s`\n' "$path" + done + printf '\n' + fi + + printf '%s\n' 'Everything else is reference material. To change behavior that lives in code you do not own, use a hook, a filter, or a template override in the source above. If that is genuinely impossible, say so rather than editing outside the list.' printf '\n' - printf '%s\n' 'If a task seems to require changing one, stop and ask the operator which paths this site actually captures. Do not assume. The answer belongs to the operator'"'"'s capture configuration, not to you.' + printf '%s\n' '**Your edits are live the moment you save them.** There is no staging environment and no review gate between you and the public site, so verify each change — load the affected page or run the relevant WP-CLI command — before you leave it in place.' } diff --git a/lib/repair-opencode-json.py b/lib/repair-opencode-json.py index e0eb761..37d2e5c 100755 --- a/lib/repair-opencode-json.py +++ b/lib/repair-opencode-json.py @@ -76,15 +76,37 @@ MANAGED_KIMAKI_PLUGIN_NAMES = {"dm-context-filter.ts", "dm-agent-sync.ts"} OBSOLETE_KIMAKI_PLUGIN_NAMES = {"homeboy-notification-context.ts"} DM_MEMORY_MARKER = "/datamachine-files/" -# Every installed-source root wp-coding-agents manages, in canonical order. -# These are denied under BOTH postures. wp-content/plugins holds WooCommerce, -# payment gateways, and the agent's own Data Machine runtime; wp-content/themes -# holds the stock bundled themes. Managed hosting does not open these -# directories — it opens declared paths inside them. +# Every installed path wp-coding-agents manages, as ready-made edit patterns in +# canonical order. Denied under BOTH postures. +# +# Directories carry /**; root files are exact literals. That distinction is not +# cosmetic: OpenCode's matcher turns `*` into `.*`, which spans slashes, so a +# pattern like `wp-*.php` would also match wp-content/plugins/acme/wp-thing.php +# and over-deny inside a site's own component. Literals anchor to the root. +# +# wp-admin and the root bootstrap are core, siblings of wp-includes rather than +# nested in it. wp-content/mu-plugins is agent governance: the mu-plugin that +# generates AGENTS.md lives there. MANAGED_ROOTS = ( + "wp-admin/**", + "wp-includes/**", "wp-content/plugins/**", "wp-content/themes/**", - "wp-includes/**", + "wp-content/mu-plugins/**", + "wp-config.php", + "wp-settings.php", + "wp-load.php", + "wp-blog-header.php", + "wp-cron.php", + "wp-login.php", + "wp-mail.php", + "wp-signup.php", + "wp-activate.php", + "wp-trackback.php", + "wp-comments-post.php", + "wp-links-opml.php", + "xmlrpc.php", + "index.php", ) DEFAULT_POSTURE = "engineering" @@ -369,16 +391,24 @@ def expected_edit_permission( data: dict, posture: str = DEFAULT_POSTURE, managed_sources: List[str] | None = None, + managed_writable: List[str] | None = None, + log_paths: List[str] | None = None, ) -> dict: - """Return user edit rules, then the managed roots, then owned-source allows. + """User rules, then managed denies, then the narrower allows. - KEY ORDER IS THE PRECEDENCE MECHANISM. OpenCode evaluates permissions with - findLast over a ruleset built in JSON key order, so the broad root denies - have to come before the narrower allows that carve exceptions out of them. - Emitting the allows first would silently invert the policy. + KEY ORDER IS THE PRECEDENCE MECHANISM. OpenCode evaluates with findLast over + a ruleset built in JSON key order, so broad denies have to come before the + allows that carve exceptions out of them. Emitting the allows first would + silently invert the policy. """ - sources = list(managed_sources or []) if posture == "managed" else [] - managed_keys = set(MANAGED_ROOTS) | {f"{path}/**" for path in sources} + is_managed = posture == "managed" + sources = list(managed_sources or []) if is_managed else [] + writable = list(managed_writable or []) if is_managed else [] + logs = list(log_paths or []) + + source_keys = [f"{path}/**" for path in sources] + log_keys = [f"{path}/**" for path in logs] + managed_keys = set(MANAGED_ROOTS) | set(source_keys) | set(writable) | set(log_keys) permission = data.get("permission", {}) if isinstance(permission, str): @@ -401,8 +431,14 @@ def expected_edit_permission( for pattern in MANAGED_ROOTS: rules[pattern] = "deny" - for path in sources: - rules[f"{path}/**"] = "allow" + for pattern in source_keys: + rules[pattern] = "allow" + for pattern in writable: + rules[pattern] = "allow" + # Logs are diagnostic input. external_directory would otherwise inherit an + # allow for edits on them. + for pattern in log_keys: + rules[pattern] = "deny" return rules @@ -412,7 +448,10 @@ def _is_stale_managed_key(pattern: str) -> bool: Without this a path dropped from --managed-source would keep its allow rule forever, which is the drift the reconciler exists to prevent. """ - return pattern.startswith(("wp-content/plugins/", "wp-content/themes/")) and pattern.endswith("/**") + return ( + pattern.startswith(("wp-content/plugins/", "wp-content/themes/", "/")) + and pattern.endswith("/**") + ) def check_edit_permission( @@ -420,13 +459,15 @@ def check_edit_permission( runtime: str, posture: str = DEFAULT_POSTURE, managed_sources: List[str] | None = None, + managed_writable: List[str] | None = None, + log_paths: List[str] | None = None, ) -> dict: if runtime != "opencode": return {"status": "ok"} permission = data.get("permission", {}) current = permission.get("edit") if isinstance(permission, dict) else None - expected = expected_edit_permission(data, posture, managed_sources) + expected = expected_edit_permission(data, posture, managed_sources, managed_writable, log_paths) return { "status": "ok" if current == expected else "needed", "expected": expected, @@ -437,8 +478,10 @@ def apply_edit_permission( data: dict, posture: str = DEFAULT_POSTURE, managed_sources: List[str] | None = None, + managed_writable: List[str] | None = None, + log_paths: List[str] | None = None, ) -> None: - expected = expected_edit_permission(data, posture, managed_sources) + expected = expected_edit_permission(data, posture, managed_sources, managed_writable, log_paths) permission = data.get("permission", {}) if isinstance(permission, str): permission = {"*": permission} @@ -474,6 +517,20 @@ def main() -> int: dest="managed_sources", help="wp-content path this site owns and may edit under managed posture. Repeatable.", ) + parser.add_argument( + "--managed-writable", + action="append", + default=[], + dest="managed_writable", + help="Denied path this install explicitly re-opens for editing. Not captured. Repeatable.", + ) + parser.add_argument( + "--log-path", + action="append", + default=[], + dest="log_paths", + help="Absolute path outside the site root the agent may read. Repeatable.", + ) parser.add_argument( "--kimaki-plugins-dir", default="/opt/kimaki-config/plugins", @@ -543,7 +600,7 @@ def main() -> int: agent_cleanup_result = check_agent_cleanup(data) managed_instructions = read_managed_instructions(args.managed_instructions_file) instruction_sync_result = check_instruction_sync(data, managed_instructions) - edit_permission_result = check_edit_permission(data, args.runtime, args.posture, args.managed_sources) + edit_permission_result = check_edit_permission(data, args.runtime, args.posture, args.managed_sources, args.managed_writable, args.log_paths) # --- Plugin array check --- expected = expected_plugins( @@ -670,7 +727,7 @@ def main() -> int: edit_permission_status = "ok" if has_edit_permission_drift: - apply_edit_permission(data, args.posture, args.managed_sources) + apply_edit_permission(data, args.posture, args.managed_sources, args.managed_writable, args.log_paths) edit_permission_status = "synced" with open(args.file, "w", encoding="utf-8") as fh: diff --git a/lib/source-policy.sh b/lib/source-policy.sh index f786c6d..fce5fdf 100644 --- a/lib/source-policy.sh +++ b/lib/source-policy.sh @@ -46,15 +46,81 @@ SOURCE_POLICY_OPTION="wp_coding_agents_posture" SOURCE_POLICY_DEFAULT_POSTURE="engineering" # Newline-separated wp-content paths the site owns under managed posture. SOURCE_POLICY_OWNED_OPTION="wp_coding_agents_managed_sources" +# Paths that are editable but NOT captured. Distinct from owned sources on +# purpose: --managed-source means "editable AND recorded by the operator's +# capture", which is what makes the guidance's "your work is recorded" promise +# true. wp-config.php and friends are not captured by a component harvest, so +# declaring them as sources would have AGENTS.md assert a safety property that +# does not hold for them — the #318 failure. They get their own category and +# their own, honest, prose. +SOURCE_POLICY_WRITABLE_OPTION="wp_coding_agents_managed_writable" +# Paths OUTSIDE the site root the agent may read — server logs, almost always. +# +# This exists because the limitation was backwards. We are strict about editing +# core (correct: an update overwrites it anyway) and were accidentally strict +# about READING the one thing needed to recover from a fatal. OpenCode gates +# anything touching a path outside the project directory behind +# `external_directory`, which defaults to "ask" — and an autonomous agent has +# nobody to ask. So on a live site the single most important recovery +# capability sat behind a prompt that could never be answered. +# +# Read only. These are granted through external_directory and then explicitly +# denied for edit, so the agent can diagnose without rewriting a log. +SOURCE_POLICY_LOG_OPTION="wp_coding_agents_log_paths" # Every installed-source root wp-coding-agents has an opinion about, in the # order they are emitted by every consumer. Adding a root here adds it to all # runtimes and to the AGENTS.md prose at once. +# Every installed path wp-coding-agents has an opinion about, as +# `\t` lines in canonical order. +# +# The kind matters because the three runtimes format these differently, and +# because of a glob trap: OpenCode's matcher turns `*` into `.*`, which SPANS +# SLASHES (packages/opencode/src/util/wildcard.ts). A pattern like `wp-*.php` +# meant for root bootstrap files would therefore also match +# `wp-content/plugins/acme/wp-thing.php` and over-deny inside a site's own +# component. Root files must be exact literals; `Wildcard.match` anchors ^...$ +# so a literal matches only that exact relative path. +# +# WordPress core is `wp-admin/` AND `wp-includes/` plus the root bootstrap — +# they are siblings, not nested. Listing only wp-includes (the historical +# behavior) left wp-admin and every root PHP file, including wp-config.php, +# editable on every install. +# +# `wp-content/mu-plugins/` is here because it is AGENT GOVERNANCE, not a site +# extension: wp-coding-agents installs the mu-plugin that GENERATES AGENTS.md +# there, alongside the channel and runtime registries. An agent able to edit +# that directory can rewrite its own instructions. Same reasoning as +# wp-config.php, which holds the constants that gate composition at all. +# +# `wp-content/uploads/` is deliberately NOT here. The agent's own memory files +# live under it and it has to be able to write them. _source_policy_all_roots() { printf '%s\n' \ - 'wp-content/plugins' \ - 'wp-content/themes' \ - 'wp-includes' + 'wp-admin dir' \ + 'wp-includes dir' \ + 'wp-content/plugins dir' \ + 'wp-content/themes dir' \ + 'wp-content/mu-plugins dir' \ + 'wp-config.php file' \ + 'wp-settings.php file' \ + 'wp-load.php file' \ + 'wp-blog-header.php file' \ + 'wp-cron.php file' \ + 'wp-login.php file' \ + 'wp-mail.php file' \ + 'wp-signup.php file' \ + 'wp-activate.php file' \ + 'wp-trackback.php file' \ + 'wp-comments-post.php file' \ + 'wp-links-opml.php file' \ + 'xmlrpc.php file' \ + 'index.php file' +} + +# Just the paths, for callers that do not care about the kind. +_source_policy_all_root_paths() { + _source_policy_all_roots | cut -f1 } source_policy_is_valid() { @@ -176,6 +242,82 @@ source_policy_owned_sources() { done } +# Declared editable-but-not-captured paths. Empty unless managed. +source_policy_writable_paths() { + if ! source_policy_is_managed; then + return 0 + fi + + printf '%s\n' "${MANAGED_WRITABLE:-}" | while IFS= read -r path; do + [ -n "$path" ] || continue + printf '%s\n' "$path" + done +} + +# Declared read-only paths outside the site root. +source_policy_log_paths() { + printf '%s\n' "${MANAGED_LOG_PATHS:-}" | while IFS= read -r path; do + [ -n "$path" ] || continue + printf '%s\n' "$path" + done +} + +source_policy_resolve_log_paths() { + if [ "${MANAGED_LOG_PATHS_EXPLICIT:-false}" = true ]; then + MANAGED_LOG_PATHS="$(_source_policy_normalize_log_paths "${MANAGED_LOG_PATHS:-}")" + else + MANAGED_LOG_PATHS="$(_source_policy_normalize_log_paths "$(source_policy_recorded_log_paths)")" + fi +} + +source_policy_recorded_log_paths() { + if [ "${DRY_RUN:-false}" = true ]; then + printf '%s' "${MANAGED_LOG_PATHS:-}" + return 0 + fi + if [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then + return 0 + fi + wp_cmd option get "$SOURCE_POLICY_LOG_OPTION" 2>/dev/null || true +} + +source_policy_record_log_paths() { + local paths="${MANAGED_LOG_PATHS:-}" + + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_LOG_OPTION '<${paths}>'" + return 0 + fi + if [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then + return 0 + fi + if [ "$(source_policy_recorded_log_paths)" = "$paths" ]; then + return 0 + fi + if printf '%s' "$paths" | wp_cmd option update "$SOURCE_POLICY_LOG_OPTION" >/dev/null 2>&1; then + log " Recorded readable log paths: $(printf '%s' "$paths" | tr '\n' ' ')" + else + warn "Could not record readable log paths" + fi +} + +# Must be absolute. A relative path here would be inside the site root, where +# external_directory does not apply and the grant would silently do nothing. +_source_policy_normalize_log_paths() { + printf '%s\n' "${1:-}" | tr ' ' '\n' | while IFS= read -r path; do + path="${path%/}" + [ -n "$path" ] || continue + case "$path" in + /*) ;; + *) + warn " Ignoring log path '$path': must be absolute" >&2 + continue + ;; + esac + printf '%s\n' "$path" + done +} + source_policy_is_managed() { [ "${POSTURE:-$SOURCE_POLICY_DEFAULT_POSTURE}" = managed ] } @@ -208,6 +350,68 @@ source_policy_resolve_owned_sources() { fi } +source_policy_resolve_writable_paths() { + if ! source_policy_is_managed; then + MANAGED_WRITABLE="" + return 0 + fi + + if [ "${MANAGED_WRITABLE_EXPLICIT:-false}" = true ]; then + MANAGED_WRITABLE="$(_source_policy_normalize_writable "${MANAGED_WRITABLE:-}")" + else + MANAGED_WRITABLE="$(_source_policy_normalize_writable "$(source_policy_recorded_writable_paths)")" + fi +} + +source_policy_recorded_writable_paths() { + if [ "${DRY_RUN:-false}" = true ]; then + printf '%s' "${MANAGED_WRITABLE:-}" + return 0 + fi + if [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then + return 0 + fi + wp_cmd option get "$SOURCE_POLICY_WRITABLE_OPTION" 2>/dev/null || true +} + +source_policy_record_writable_paths() { + source_policy_is_managed || return 0 + local paths="${MANAGED_WRITABLE:-}" + + if [ "${DRY_RUN:-false}" = true ]; then + echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_WRITABLE_OPTION '<${paths}>'" + return 0 + fi + if [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then + return 0 + fi + if [ "$(source_policy_recorded_writable_paths)" = "$paths" ]; then + return 0 + fi + if printf '%s' "$paths" | wp_cmd option update "$SOURCE_POLICY_WRITABLE_OPTION" >/dev/null 2>&1; then + log " Recorded managed writable paths: $(printf '%s' "$paths" | tr '\n' ' ')" + else + warn "Could not record managed writable paths" + fi +} + +# Writable exceptions must name a path the policy actually denies; anything +# else is either already editable or a typo, and silently accepting it would +# leave the operator believing they granted something they did not. +_source_policy_normalize_writable() { + local known + known="$(_source_policy_all_root_paths)" + printf '%s\n' "${1:-}" | tr ' ' '\n' | while IFS= read -r path; do + path="${path#./}"; path="${path#/}"; path="${path%/}" + [ -n "$path" ] || continue + if ! printf '%s\n' "$known" | grep -qxF "$path"; then + warn " Ignoring writable path '$path': not one of the paths this policy denies" >&2 + continue + fi + printf '%s\n' "$path" + done +} + _source_policy_normalize_sources() { printf '%s\n' "${1:-}" | tr ' ' '\n' | while IFS= read -r path; do path="${path#./}" @@ -327,15 +531,38 @@ source_policy_workspace_enabled() { # the pre-posture behavior. Managed emits the same three denies, then one allow # per declared owned source. source_policy_edit_rules() { - local path + local path kind + local writable owned - while IFS= read -r path; do + # Denies first. ORDER IS LOAD-BEARING: OpenCode resolves with findLast over a + # ruleset built in config key order, so every broad deny must precede the + # narrower allows that carve exceptions out of it. + while IFS=$'\t' read -r path kind; do [ -n "$path" ] || continue - printf '%s\tdeny\n' "$path" + printf '%s\t%s\tdeny\n' "$path" "$kind" done < <(source_policy_read_only_roots) + # Owned source trees: editable AND captured. while IFS= read -r path; do [ -n "$path" ] || continue - printf '%s\tallow\n' "$path" + printf '%s\tdir\tallow\n' "$path" done < <(source_policy_owned_sources) + + # Declared exceptions: editable, NOT captured. + while IFS= read -r path; do + [ -n "$path" ] || continue + printf '%s\tfile\tallow\n' "$path" + done < <(source_policy_writable_paths) +} + +# Render one rule path for a runtime that uses glob patterns (OpenCode, +# Claude Code). Directories get /**; files stay literal so they anchor to the +# site root instead of matching same-named files inside components. +source_policy_pattern() { + local path="$1" kind="$2" + if [ "$kind" = dir ]; then + printf '%s/**' "$path" + else + printf '%s' "$path" + fi } diff --git a/runtimes/claude-code.sh b/runtimes/claude-code.sh index ce99076..8629dd0 100644 --- a/runtimes/claude-code.sh +++ b/runtimes/claude-code.sh @@ -219,11 +219,12 @@ runtime_install_hooks() { # rather than emitting a permission set that would silently lock the agent # out of its own source. See lib/source-policy.sh. local wordpress_deny_rules='[]' - local _path _action - while IFS=$'\t' read -r _path _action; do + local _path _kind _pattern + while IFS=$'\t' read -r _path _kind; do [ -n "$_path" ] || continue + _pattern="$(source_policy_pattern "$_path" "$_kind")" local _rule - _rule=$(jq -n --arg site "$SITE_PATH" --arg root "$_path" '"Edit(\($site)/\($root)/**)"') + _rule=$(jq -n --arg site "$SITE_PATH" --arg pat "$_pattern" '"Edit(\($site)/\($pat))"') wordpress_deny_rules=$(jq -n --argjson acc "$wordpress_deny_rules" --argjson rule "$_rule" '$acc + [$rule]') done < <(source_policy_read_only_roots) diff --git a/runtimes/codex.sh b/runtimes/codex.sh index d8f7fdf..0c3d09d 100644 --- a/runtimes/codex.sh +++ b/runtimes/codex.sh @@ -111,8 +111,8 @@ runtime_generate_config() { # profile; anything the posture makes editable inherits the ":workspace" # default. See lib/source-policy.sh. local codex_read_roots="" - local _root - while IFS= read -r _root; do + local _root _kind + while IFS=$'\t' read -r _root _kind; do [ -n "$_root" ] || continue codex_read_roots="${codex_read_roots}${_root}"$'\n' done < <(source_policy_read_only_roots) diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index 7a07dfd..8134f07 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -288,9 +288,26 @@ runtime_generate_config() { # workspace instead; managed inverts that for themes and plugins and grants # no workspace, because there isn't one. OPENCODE_JSON="$OPENCODE_JSON,\n \"permission\": {" + + # external_directory covers anything outside the site root. Engineering gets + # the DMC workspace; every posture gets whatever log paths the operator + # declared, because a coding agent that cannot read the PHP error log cannot + # recover the site it just broke. + local _ext_rules="" if source_policy_workspace_enabled; then - OPENCODE_JSON="$OPENCODE_JSON\n \"external_directory\": {" - OPENCODE_JSON="$OPENCODE_JSON\n \"${DM_WORKSPACE_DIR}/**\": \"allow\"" + _ext_rules="\n \"${DM_WORKSPACE_DIR}/**\": \"allow\"" + fi + local _log_path + while IFS= read -r _log_path; do + [ -n "$_log_path" ] || continue + if [ -n "$_ext_rules" ]; then + _ext_rules="${_ext_rules}," + fi + _ext_rules="${_ext_rules}\n \"${_log_path}/**\": \"allow\"" + done < <(source_policy_log_paths) + + if [ -n "$_ext_rules" ]; then + OPENCODE_JSON="$OPENCODE_JSON\n \"external_directory\": {${_ext_rules}" OPENCODE_JSON="$OPENCODE_JSON\n }," fi OPENCODE_JSON="$OPENCODE_JSON\n \"edit\": {" @@ -300,14 +317,24 @@ runtime_generate_config() { # written before the narrower owned-source allows that carve exceptions out # of them. source_policy_edit_rules emits them in exactly that order. local _edit_rules="" - local _path _action - while IFS=$'\t' read -r _path _action; do + local _path _kind _action _pattern + while IFS=$'\t' read -r _path _kind _action; do [ -n "$_path" ] || continue + _pattern="$(source_policy_pattern "$_path" "$_kind")" if [ -n "$_edit_rules" ]; then _edit_rules="${_edit_rules}," fi - _edit_rules="${_edit_rules}\n \"${_path}/**\": \"${_action}\"" + _edit_rules="${_edit_rules}\n \"${_pattern}\": \"${_action}\"" done < <(source_policy_edit_rules) + # Granting external_directory would otherwise inherit the workspace default + # of allow for edits. Logs are diagnostic input, never something to rewrite. + while IFS= read -r _log_path; do + [ -n "$_log_path" ] || continue + if [ -n "$_edit_rules" ]; then + _edit_rules="${_edit_rules}," + fi + _edit_rules="${_edit_rules}\n \"${_log_path}/**\": \"deny\"" + done < <(source_policy_log_paths) OPENCODE_JSON="$OPENCODE_JSON${_edit_rules}" OPENCODE_JSON="$OPENCODE_JSON\n }" OPENCODE_JSON="$OPENCODE_JSON\n }" diff --git a/setup.sh b/setup.sh index 91ffa1b..87da672 100755 --- a/setup.sh +++ b/setup.sh @@ -76,6 +76,10 @@ POSTURE="" POSTURE_EXPLICIT=false MANAGED_SOURCES="" MANAGED_SOURCES_EXPLICIT=false +MANAGED_WRITABLE="" +MANAGED_WRITABLE_EXPLICIT=false +MANAGED_LOG_PATHS="" +MANAGED_LOG_PATHS_EXPLICIT=false HOMEBOY_PROJECT_ID="${HOMEBOY_PROJECT_ID:-}" DETECTED_RUNTIMES=() IS_STUDIO=false @@ -208,6 +212,16 @@ while [[ $# -gt 0 ]]; do MANAGED_SOURCES_EXPLICIT=true shift 2 ;; + --managed-writable) + MANAGED_WRITABLE="${MANAGED_WRITABLE}${MANAGED_WRITABLE:+ }$2" + MANAGED_WRITABLE_EXPLICIT=true + shift 2 + ;; + --log-path) + MANAGED_LOG_PATHS="${MANAGED_LOG_PATHS}${MANAGED_LOG_PATHS:+ }$2" + MANAGED_LOG_PATHS_EXPLICIT=true + shift 2 + ;; --agent-slug) AGENT_SLUG="$2" AGENT_SLUG_EXPLICIT=true @@ -446,6 +460,8 @@ detect_environment # runtime permission surfaces, and the AGENTS.md guidance all derive from it. source_policy_resolve_posture source_policy_resolve_owned_sources +source_policy_resolve_writable_paths +source_policy_resolve_log_paths source_policy_assert_runtime_supports_posture if [ "$INSTALL_CHAT" = true ] && [ "$CHAT_BRIDGE" = "kimaki" ] && [ "$LOCAL_MODE" = false ]; then @@ -481,6 +497,8 @@ fi source_policy_record_posture source_policy_record_owned_sources +source_policy_record_writable_paths +source_policy_record_log_paths guidance_sync_all setup_ai_gateway diff --git a/tests/opencode-local-plugin-path.sh b/tests/opencode-local-plugin-path.sh index 28d224f..47740d5 100755 --- a/tests/opencode-local-plugin-path.sh +++ b/tests/opencode-local-plugin-path.sh @@ -54,13 +54,13 @@ actual = data.get("plugin") if actual != expected: raise SystemExit(f"unexpected local plugin paths: {actual}") -expected_edit = { - "wp-content/plugins/**": "deny", - "wp-content/themes/**": "deny", - "wp-includes/**": "deny", -} -if data.get("permission", {}).get("edit") != expected_edit: - raise SystemExit(f"unexpected edit permissions: {data.get('permission')}") +edit = data.get("permission", {}).get("edit", {}) +for required in ("wp-admin/**", "wp-includes/**", "wp-content/plugins/**", + "wp-content/themes/**", "wp-content/mu-plugins/**", "wp-config.php"): + if edit.get(required) != "deny": + raise SystemExit(f"installed source not denied: {required} -> {edit.get(required)}") +if set(edit.values()) != {"deny"}: + raise SystemExit(f"engineering must grant no edit allow: {edit}") PY if [ ! -f "$SITE_PATH/.opencode/plugins/claude-code-auth.ts" ]; then @@ -95,13 +95,13 @@ actual = data.get("plugin") if actual != expected: raise SystemExit(f"unexpected opt-out plugin paths: {actual}") -expected_edit = { - "wp-content/plugins/**": "deny", - "wp-content/themes/**": "deny", - "wp-includes/**": "deny", -} -if data.get("permission", {}).get("edit") != expected_edit: - raise SystemExit(f"unexpected edit permissions: {data.get('permission')}") +edit = data.get("permission", {}).get("edit", {}) +for required in ("wp-admin/**", "wp-includes/**", "wp-content/plugins/**", + "wp-content/themes/**", "wp-content/mu-plugins/**", "wp-config.php"): + if edit.get(required) != "deny": + raise SystemExit(f"installed source not denied: {required} -> {edit.get(required)}") +if set(edit.values()) != {"deny"}: + raise SystemExit(f"engineering must grant no edit allow: {edit}") PY if [ -f "$SITE_PATH/.opencode/plugins/claude-code-auth.ts" ]; then diff --git a/tests/posture.sh b/tests/posture.sh index cc7a0f3..a533831 100755 --- a/tests/posture.sh +++ b/tests/posture.sh @@ -67,33 +67,54 @@ source "$SCRIPT_DIR/lib/source-policy.sh" echo "==> source policy resolves the documented root matrix" # =========================================================================== +rules() { source_policy_edit_rules | awk -F'\t' '{print $1"="$3}' | tr '\n' ' '; } +has_rule() { + case " $(rules) " in *" $1 "*) return 0 ;; *) return 1 ;; esac +} +check_rule() { + if has_rule "$1"; then echo " ok $2"; else echo " FAIL $2 (missing $1)"; FAILED=$((FAILED + 1)); fi +} +refute_rule() { + if has_rule "$1"; then echo " FAIL $2 (unexpected $1)"; FAILED=$((FAILED + 1)); else echo " ok $2"; fi +} + POSTURE=engineering -MANAGED_SOURCES="" -assert_eq "$(source_policy_edit_rules | tr '\t' '=' | tr '\n' ' ')" \ - "wp-content/plugins=deny wp-content/themes=deny wp-includes=deny " \ - "engineering keeps every installed root read-only" +MANAGED_SOURCES=""; MANAGED_WRITABLE=""; MANAGED_LOG_PATHS="" + +# Core is wp-admin AND wp-includes plus the root bootstrap — siblings, not +# nested. Listing only wp-includes left wp-admin and every root PHP file, +# including wp-config.php, editable on every install ever created (#322). +check_rule "wp-admin=deny" "core admin half is read-only" +check_rule "wp-includes=deny" "core internals are read-only" +check_rule "wp-config.php=deny" "wp-config.php is read-only by default" +check_rule "wp-settings.php=deny" "root bootstrap is read-only" +check_rule "index.php=deny" "root index.php is read-only" +check_rule "wp-content/mu-plugins=deny" "agent governance (mu-plugins) is read-only" +check_rule "wp-content/plugins=deny" "installed plugins are read-only" +check_rule "wp-content/themes=deny" "installed themes are read-only" + +# The agent's own memory lives under uploads; denying it would break the agent. +refute_rule "wp-content/uploads=deny" "uploads stay writable (agent memory lives there)" +refute_rule "wp-content/uploads=allow" "uploads are not a managed rule at all" + source_policy_workspace_enabled \ && echo " ok engineering has a workspace" \ || { echo " FAIL engineering has a workspace"; FAILED=$((FAILED + 1)); } -# Managed does NOT open wp-content. It denies the same roots and then carves -# out only the declared owned paths. The regression this pins: an earlier -# version allowed `wp-content/plugins/**` wholesale, which on a live install -# meant WooCommerce, a payment gateway, and the agent's own runtime. +# Managed denies the same set and carves out only what was declared. POSTURE=managed MANAGED_SOURCES="wp-content/themes/acme wp-content/plugins/acme-core" -assert_eq "$(source_policy_edit_rules | tr '\t' '=' | tr '\n' ' ')" \ - "wp-content/plugins=deny wp-content/themes=deny wp-includes=deny wp-content/themes/acme=allow wp-content/plugins/acme-core=allow " \ - "managed denies the roots and allows only declared owned paths" - -# Order is the precedence mechanism for OpenCode findLast; a narrower allow -# emitted before the broad deny would be silently inverted. -DENY_POS=$(source_policy_edit_rules | grep -n '^wp-content/plugins deny$' | cut -d: -f1) -ALLOW_POS=$(source_policy_edit_rules | grep -n '^wp-content/plugins/acme-core allow$' | cut -d: -f1) -[ "$DENY_POS" -lt "$ALLOW_POS" ] -check_rc=$? -if [ "$check_rc" -eq 0 ]; then +MANAGED_WRITABLE=""; MANAGED_LOG_PATHS="" +check_rule "wp-content/plugins=deny" "managed still denies the plugins directory" +check_rule "wp-content/themes/acme=allow" "managed allows a declared owned theme" +check_rule "wp-content/plugins/acme-core=allow" "managed allows a declared owned plugin" +check_rule "wp-config.php=deny" "managed still denies wp-config.php by default" + +# ORDER is the precedence mechanism for OpenCode findLast. +DENY_POS=$(source_policy_edit_rules | grep -n '^wp-content/plugins dir deny$' | cut -d: -f1) +ALLOW_POS=$(source_policy_edit_rules | grep -n '^wp-content/plugins/acme-core dir allow$' | cut -d: -f1) +if [ "$DENY_POS" -lt "$ALLOW_POS" ]; then echo " ok broad deny is emitted before the narrower allow" else echo " FAIL broad deny is emitted before the narrower allow" @@ -104,27 +125,42 @@ source_policy_workspace_enabled \ && { echo " FAIL managed has no workspace"; FAILED=$((FAILED + 1)); } \ || echo " ok managed has no workspace" -# Fail closed: a managed install that declares nothing gets NO editable source -# rather than a wide-open wp-content. -POSTURE=managed +# Fail closed. MANAGED_SOURCES="" -assert_eq "$(source_policy_edit_rules | tr '\t' '=' | tr '\n' ' ')" \ - "wp-content/plugins=deny wp-content/themes=deny wp-includes=deny " \ - "managed with nothing declared grants no edit access at all" - -# Paths outside wp-content, and files inside a component, are rejected rather -# than silently trusted. -MANAGED_SOURCES_EXPLICIT=true -MANAGED_SOURCES="wp-includes/foo wp-content/plugins/acme/file.php wp-content/plugins/acme" -source_policy_resolve_owned_sources 2>/dev/null -assert_eq "$(source_policy_owned_sources | tr '\n' ' ')" "wp-content/plugins/acme " \ - "only well-formed plugin/theme directories survive normalization" +refute_rule "wp-content/themes/acme=allow" "managed with nothing declared grants no edit access" + +# Declared writable exceptions re-open a denied path, and are NOT captured. +MANAGED_SOURCES=""; MANAGED_WRITABLE="wp-config.php" +check_rule "wp-config.php=allow" "a declared writable path re-opens wp-config.php" + +# ...but only paths the policy actually denies. Anything else is a typo, and +# accepting it silently would leave the operator believing they granted something. +MANAGED_WRITABLE_EXPLICIT=true +MANAGED_WRITABLE="wp-config.php nonsense/path.php" +source_policy_resolve_writable_paths 2>/dev/null +assert_eq "$(source_policy_writable_paths | tr '\n' ' ')" "wp-config.php " \ + "unknown writable paths are rejected, not silently granted" +MANAGED_WRITABLE_EXPLICIT=false; MANAGED_WRITABLE="" + +# =========================================================================== +echo "==> the agent can read the logs it needs to recover the site" +# =========================================================================== + +# We are strict about editing core, and were accidentally strict about READING +# the one thing needed to recover from a fatal: OpenCode gates paths outside +# the site root behind external_directory, which defaults to "ask" — and an +# autonomous agent has nobody to ask (#322). +MANAGED_LOG_PATHS_EXPLICIT=true +MANAGED_LOG_PATHS="/var/log/nginx relative/nope" +source_policy_resolve_log_paths 2>/dev/null +assert_eq "$(source_policy_log_paths | tr '\n' ' ')" "/var/log/nginx " \ + "log paths must be absolute or they silently do nothing" +# The read-only-ness of a granted log path is enforced where external_directory +# is written — see the opencode section below. POSTURE=nonsense -MANAGED_SOURCES="" -assert_eq "$(source_policy_edit_rules | tr '\t' '=' | tr '\n' ' ')" \ - "wp-content/plugins=deny wp-content/themes=deny wp-includes=deny " \ - "unknown posture degrades to read-only, never to write" +MANAGED_SOURCES=""; MANAGED_WRITABLE="" +refute_rule "wp-content/plugins=allow" "unknown posture never grants write" # =========================================================================== echo "==> runtimes that cannot express scoped permissions refuse managed" @@ -154,12 +190,14 @@ else FAILED=$((FAILED + 1)) fi +MANAGED_LOG_PATHS_EXPLICIT=false; MANAGED_LOG_PATHS="" + # =========================================================================== echo "==> opencode.json permission surface follows posture" # =========================================================================== _opencode_config_for() { - local posture="$1" out="$2" sources="${3:-}" + local posture="$1" out="$2" sources="${3:-}" logs="${4:-}" ( TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -175,6 +213,8 @@ _opencode_config_for() { UPDATED_ITEMS=() POSTURE="$posture" MANAGED_SOURCES="$sources" + MANAGED_WRITABLE="" + MANAGED_LOG_PATHS="$logs" # shellcheck disable=SC1091 source "$SCRIPT_DIR/runtimes/opencode.sh" runtime_generate_config @@ -186,25 +226,37 @@ ENG_JSON="$(mktemp)"; MGD_JSON="$(mktemp)" trap 'rm -f "$ENG_JSON" "$MGD_JSON"' EXIT _opencode_config_for engineering "$ENG_JSON" _opencode_config_for managed "$MGD_JSON" "wp-content/themes/acme -wp-content/plugins/acme-core" - -assert_eq "$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["permission"]["edit"],sort_keys=True))' "$ENG_JSON")" \ - '{"wp-content/plugins/**": "deny", "wp-content/themes/**": "deny", "wp-includes/**": "deny"}' \ - "engineering opencode edit map is byte-identical to the pre-refactor rules" - -# sort_keys would destroy the very thing under test, so compare raw order. -assert_eq "$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["permission"]["edit"]))' "$MGD_JSON")" \ - '{"wp-content/plugins/**": "deny", "wp-content/themes/**": "deny", "wp-includes/**": "deny", "wp-content/themes/acme/**": "allow", "wp-content/plugins/acme-core/**": "allow"}' \ - "managed opencode edit map denies the roots then allows owned paths, in that order" - -MGD_EDIT="$(python3 -c 'import json,sys; print(" ".join(json.load(open(sys.argv[1]))["permission"]["edit"]))' "$MGD_JSON")" -refute_contains "$MGD_EDIT" 'wp-content/plugins/**": "allow' \ +wp-content/plugins/acme-core" "/var/log/site" + +ENG_EDIT="$(python3 -c 'import json,sys; print(" ".join(json.load(open(sys.argv[1]))["permission"]["edit"]))' "$ENG_JSON")" +assert_contains "$ENG_EDIT" 'wp-admin/**' "engineering denies wp-admin" +assert_contains "$ENG_EDIT" 'wp-config.php' "engineering denies wp-config.php" +assert_contains "$ENG_EDIT" 'wp-content/mu-plugins/**' "engineering denies mu-plugins" +refute_contains "$ENG_EDIT" 'wp-content/uploads' "engineering leaves uploads alone" +assert_eq "$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1]))["permission"]["edit"]; print(sorted(set(d.values())))' "$ENG_JSON")" \ + "['deny']" "engineering grants no edit allow anywhere in the installed tree" + +MGD_EDIT_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["permission"]["edit"]))' "$MGD_JSON")" +MGD_KEYS="$(python3 -c 'import json,sys; print(" ".join(json.load(open(sys.argv[1]))["permission"]["edit"]))' "$MGD_JSON")" +assert_contains "$MGD_EDIT_JSON" '"wp-content/plugins/**": "deny"' \ "managed never opens the whole plugins directory" +assert_contains "$MGD_EDIT_JSON" '"wp-content/themes/acme/**": "allow"' \ + "managed allows the declared owned theme" +# sort_keys would destroy the property under test; compare positions instead. +assert_eq "$(python3 -c ' +import json,sys +k=list(json.load(open(sys.argv[1]))["permission"]["edit"]) +print(k.index("wp-content/themes/**") < k.index("wp-content/themes/acme/**"))' "$MGD_JSON")" \ + "True" "managed emits the broad deny before the narrower allow" +assert_contains "$MGD_EDIT_JSON" '"/var/log/site/**": "deny"' \ + "a readable log path is still denied for editing" assert_eq "$(python3 -c 'import json,sys; print("yes" if "external_directory" in json.load(open(sys.argv[1]))["permission"] else "no")' "$ENG_JSON")" \ "yes" "engineering grants the workspace directory" -assert_eq "$(python3 -c 'import json,sys; print("yes" if "external_directory" in json.load(open(sys.argv[1]))["permission"] else "no")' "$MGD_JSON")" \ - "no" "managed grants no workspace directory (there is none)" +MGD_EXT="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["permission"].get("external_directory",{})))' "$MGD_JSON")" +refute_contains "$MGD_EXT" 'workspace' "managed grants no workspace directory (there is none)" +assert_contains "$MGD_EXT" '"/var/log/site/**": "allow"' \ + "managed grants read on the declared log path, so the agent can debug a fatal" # =========================================================================== echo "==> claude-code denies every installed root (managed is refused upstream)" @@ -289,54 +341,70 @@ assert_contains "$ENG_PROSE" "managed workspace" "engineering prose routes chang POSTURE=managed MANAGED_SOURCES="wp-content/themes/acme wp-content/plugins/acme-core" +MANAGED_WRITABLE="wp-config.php" MGD_PROSE="$(guidance_call wordpress-source render)" assert_eq "$(guidance_call wordpress-source id)" "wordpress-source" \ "managed variant registers the same section id" -# The prose must ENUMERATE, never generalise to a directory. Saying "this -# site's theme and plugins" while the policy lists two paths is how an agent -# concludes WooCommerce is fair game. +# PURPOSE: this section exists so the agent is an expert on its own runtime by +# reading it. Reference comes first; the ownership boundary is a qualifier. +# Restriction is the permission layer's job and is enforced there. #322. +assert_contains "$MGD_PROSE" 'Read it to verify core APIs, hooks, conventions, and runtime behavior' \ + "managed prose leads with reference, not restriction" +assert_contains "$MGD_PROSE" '`wp-admin/`' \ + "managed prose points at wp-admin — core is not just wp-includes" +assert_contains "$MGD_PROSE" '`wp-includes/`' \ + "managed prose points at wp-includes" +assert_contains "$MGD_PROSE" 'ground truth' \ + "managed prose frames installed source as authoritative" + + +# ENUMERATE, never generalise (#318). assert_contains "$MGD_PROSE" '- `wp-content/themes/acme/`' \ "managed prose names each editable path" assert_contains "$MGD_PROSE" '- `wp-content/plugins/acme-core/`' \ "managed prose names every editable path, not just the first" -assert_contains "$MGD_PROSE" 'this is the complete list' \ +assert_contains "$MGD_PROSE" 'the complete list' \ "managed prose states the editable list is exhaustive" -refute_contains "$MGD_PROSE" '`wp-content/plugins/` -- **editable**' \ +refute_contains "$MGD_PROSE" '`wp-content/plugins/` — **editable**' \ "managed prose never presents a whole directory as editable" -assert_contains "$MGD_PROSE" 'The rest of `wp-content/plugins/`' \ - "managed prose marks the remaining plugins read-only" -assert_contains "$MGD_PROSE" '`wp-includes/` ' \ - "managed prose keeps core read-only" + +# A writable exception must NOT inherit the "your work is recorded" promise. +assert_contains "$MGD_PROSE" 'nothing captures them' \ + "managed prose says declared writable paths are not captured" +assert_contains "$MGD_PROSE" '- `wp-config.php`' \ + "managed prose names the writable exception" + assert_contains "$MGD_PROSE" 'live the moment you save' \ "managed prose states that edits reach production immediately" -assert_contains "$MGD_PROSE" 'not every file is authored source' \ - "managed prose warns that installed and generated files are not captured" -assert_contains "$MGD_PROSE" 'no pull request step' \ - "managed prose rules out the review workflow rather than leaving it implied" refute_contains "$MGD_PROSE" "Make code changes in the configured managed workspace" \ "managed prose never routes work to a workspace that does not exist" # This text ships to EVERY managed install, so it must not describe one site's -# stack as if it were universal. #320: it named commerce and payment code, "the -# site's ability to take money", called the remaining plugins "the runtime that -# gives you memory and tools", and listed one operator's harvest excludes -# verbatim — a config wp-coding-agents does not own and cannot read. Assert the -# CATEGORY and the REASON; never the specifics. -for term in WooCommerce Stripe commerce payment money composer.lock package-lock node_modules "Data Machine" homeboy harvest.yml wp-admin; do +# stack as if it were universal (#320). +for term in WooCommerce Stripe commerce payment money composer.lock package-lock node_modules "Data Machine" homeboy harvest.yml; do refute_contains "$MGD_PROSE" "$term" \ "managed prose does not assume '$term' exists on this install" done -# Fail closed in prose too: nothing declared must not read as "edit anything". -MANAGED_SOURCES="" +# Fail closed in prose too. +MANAGED_SOURCES=""; MANAGED_WRITABLE="" NONE_PROSE="$(guidance_call wordpress-source render)" -assert_contains "$NONE_PROSE" 'declares no editable source' \ +assert_contains "$NONE_PROSE" 'Nothing on this install is declared as editable' \ "managed prose with nothing declared says so explicitly" -refute_contains "$NONE_PROSE" 'You edit this site directly' \ - "managed prose with nothing declared does not invite edits" +assert_contains "$NONE_PROSE" 'Read it to verify core APIs' \ + "managed prose keeps the reference material even with nothing editable" MANAGED_SOURCES="wp-content/themes/acme" +# Engineering keeps the same capability framing. +POSTURE=engineering +ENG_PROSE2="$(guidance_call wordpress-source render)" +assert_contains "$ENG_PROSE2" '`wp-admin/`' \ + "engineering prose also points at wp-admin" +assert_contains "$ENG_PROSE2" 'ground truth' \ + "engineering prose frames installed source as authoritative" +POSTURE=managed + # The homeboy unit is engineering-only: its routing advice is about cooking # tracked changes in managed worktrees, which does not exist under managed. POSTURE=managed @@ -365,9 +433,17 @@ data = json.load(open(sys.argv[1])) print(json.dumps(mod.expected_edit_permission(data, "managed", ["wp-content/plugins/acme-core"]))) PYX )" -assert_eq "$RECON_OUT" \ - '{"custom/**": "ask", "wp-content/plugins/**": "deny", "wp-content/themes/**": "deny", "wp-includes/**": "deny", "wp-content/plugins/acme-core/**": "allow"}' \ - "reconciler emits denies before owned allows and preserves operator rules" +assert_contains "$RECON_OUT" '"custom/**": "ask"' \ + "reconciler preserves operator rules" +assert_contains "$RECON_OUT" '"wp-content/plugins/acme-core/**": "allow"' \ + "reconciler writes the owned-source allow" +assert_contains "$RECON_OUT" '"wp-admin/**": "deny"' \ + "reconciler denies wp-admin" +assert_eq "$(python3 -c ' +import json,sys +k=list(json.loads(sys.argv[1])) +print(k.index("wp-content/plugins/**") < k.index("wp-content/plugins/acme-core/**"))' "$RECON_OUT")" \ + "True" "reconciler emits denies before owned allows" rm -f "$RECON_IN" if [ "$FAILED" -ne 0 ]; then diff --git a/tests/repair-opencode-json.sh b/tests/repair-opencode-json.sh index e378508..63241c3 100755 --- a/tests/repair-opencode-json.sh +++ b/tests/repair-opencode-json.sh @@ -210,16 +210,20 @@ import sys with open(sys.argv[1], encoding="utf-8") as handle: data = json.load(handle) -expected = { - "*": "allow", - "docs/**": "ask", - "wp-content/plugins/**": "deny", - "wp-content/themes/**": "deny", - "wp-includes/**": "deny", -} permission = data.get("permission", {}) -if permission.get("edit") != expected: - raise SystemExit(f"unexpected managed edit rules: {permission.get('edit')}") +edit = permission.get("edit", {}) +# Operator rules survive, and they keep their position ahead of the managed +# block so the managed denies still win under findLast. +for pattern, action in (("*", "allow"), ("docs/**", "ask")): + if edit.get(pattern) != action: + raise SystemExit(f"operator rule lost: {pattern} -> {edit.get(pattern)}") +keys = list(edit) +if keys.index("docs/**") > keys.index("wp-admin/**"): + raise SystemExit(f"operator rules must precede the managed block: {keys}") +for required in ("wp-admin/**", "wp-includes/**", "wp-content/plugins/**", + "wp-content/themes/**", "wp-content/mu-plugins/**", "wp-config.php"): + if edit.get(required) != "deny": + raise SystemExit(f"installed source not denied: {required} -> {edit.get(required)}") if permission.get("bash") != "allow": raise SystemExit(f"user bash permission was not preserved: {permission}") PY diff --git a/upgrade.sh b/upgrade.sh index 6d4532b..e647b83 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -108,6 +108,10 @@ POSTURE="" POSTURE_EXPLICIT=false MANAGED_SOURCES="" MANAGED_SOURCES_EXPLICIT=false +MANAGED_WRITABLE="" +MANAGED_WRITABLE_EXPLICIT=false +MANAGED_LOG_PATHS="" +MANAGED_LOG_PATHS_EXPLICIT=false # Defaults setup.sh expects (detect.sh reads these) LOCAL_MODE=false @@ -150,6 +154,8 @@ while [[ $# -gt 0 ]]; do --rotate-ai-gateway-token) ROTATE_AI_GATEWAY_TOKEN=true; shift ;; --posture) POSTURE="$2"; POSTURE_EXPLICIT=true; shift 2 ;; --managed-source) MANAGED_SOURCES="${MANAGED_SOURCES}${MANAGED_SOURCES:+ }$2"; MANAGED_SOURCES_EXPLICIT=true; shift 2 ;; + --managed-writable) MANAGED_WRITABLE="${MANAGED_WRITABLE}${MANAGED_WRITABLE:+ }$2"; MANAGED_WRITABLE_EXPLICIT=true; shift 2 ;; + --log-path) MANAGED_LOG_PATHS="${MANAGED_LOG_PATHS}${MANAGED_LOG_PATHS:+ }$2"; MANAGED_LOG_PATHS_EXPLICIT=true; shift 2 ;; --runtime) RUNTIME="$2"; shift 2 ;; --wp-path) EXISTING_WP="$2"; shift 2 ;; --agent-slug) AGENT_SLUG="$2"; AGENT_SLUG_EXPLICIT=true; shift 2 ;; @@ -351,9 +357,13 @@ detect_environment # engineering; --posture overrides and re-records. source_policy_resolve_posture source_policy_resolve_owned_sources +source_policy_resolve_writable_paths +source_policy_resolve_log_paths source_policy_assert_runtime_supports_posture source_policy_record_posture source_policy_record_owned_sources +source_policy_record_writable_paths +source_policy_record_log_paths # Detect chat bridge from installed services / installed binaries via the # bridges/_dispatch.sh registry walk. See bridge_detect_local /