From ad45c072f3b21004d7e11a3486c7e94cfd51532a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 5 Aug 2026 02:57:08 +0000 Subject: [PATCH] refactor: rename posture to source mode (workspace | owned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--posture engineering|managed` reads as a level, as though engineering were the unrestricted one and managed the safe one. It is not a level, and it is backwards: engineering is strictly MORE restricted on live source, where the installed tree is read-only reference. What it buys is git and review, not latitude. The new names say where a change LANDS, which is the actual difference: workspace installed source is read-only reference; changes go through a Data Machine Code workspace, git, and GitHub. Recorded by review. owned the agent edits the site's declared components in place; no workspace, no git, no GitHub. Recorded by the operator's out-of-band capture. Neither implies rank, which is the whole point. #324 also asks for a third `unchained` shape. Not added: that decision already went the other way — capability correlates with the mode, the dev-toolchain/server-admin line is not enforceable in a shell (npm, composer, and git hooks all execute arbitrary code), and nobody wants the combination it would carve out. Renaming does not depend on it. The default is deliberately unchanged. `workspace` is exactly today's `engineering`, so no install changes behaviour. #324 additionally proposes making the owned mode the default; that is a product decision about what a fresh install should get, it is separable from the rename, and bundling them would make both harder to review and to revert. Existing installs carry across without the operator touching anything: - `--posture` remains an accepted alias, and engineering/managed are translated wherever a mode is read, including in the Python reconciler, which is reachable directly and has to agree with the shell. - The three recorded wp_options are renamed, and reads fall back to the pre-rename keys. Deliberately a fallback rather than a one-shot upgrade migration: a newer upgrade.sh can meet an older install's options at any time, and `wp option get` returning empty is indistinguishable from "recorded as empty". - record_mode compares against the NEW key only. Comparing through the legacy-aware reader would see a pre-rename install as already correct — the canonical value of `engineering` IS `workspace` — and leave it on the old key forever, so the migration would never actually run. Verified the rename changes no behaviour, which is the only property that matters here: the reconciler's output and a fully rendered opencode.json are both byte-identical to origin/main across all four combinations (old engineering vs new workspace, old managed vs new owned, and new code fed the legacy names). Checked against the live install too — it records `engineering` under the old key and resolves to `workspace`, read-only. --- .github/workflows/shell.yml | 8 +- README.md | 46 ++-- guidance/_dispatch.sh | 32 +-- guidance/abilities.sh | 4 +- guidance/homeboy.sh | 2 +- ...e.managed.sh => wordpress-source.owned.sh} | 4 +- ...ering.sh => wordpress-source.workspace.sh} | 4 +- lib/agents-md-guidance.sh | 2 +- lib/data-machine.sh | 4 +- lib/repair-opencode-json.py | 71 ++--- lib/runtime-guard.sh | 6 +- lib/service-migration.sh | 66 +++-- lib/source-policy.sh | 227 ++++++++++------ lib/summary.sh | 2 +- runtimes/claude-code.sh | 4 +- runtimes/codex.sh | 4 +- runtimes/opencode.sh | 6 +- setup.sh | 72 ++--- tests/agents-md-guidance.sh | 2 +- tests/claude-code-permissions.sh | 2 +- tests/codex-permissions.sh | 2 +- tests/codex-runtime.sh | 2 +- tests/homeboy-agents-md.sh | 2 +- tests/opencode-local-plugin-path.sh | 2 +- tests/runtime-guard.sh | 12 +- tests/service-migration.sh | 20 +- tests/{posture.sh => source-mode.sh} | 247 ++++++++++++------ tests/worktree-context-projections.sh | 2 +- upgrade.sh | 77 +++--- 29 files changed, 558 insertions(+), 376 deletions(-) rename guidance/{wordpress-source.managed.sh => wordpress-source.owned.sh} (94%) rename guidance/{wordpress-source.engineering.sh => wordpress-source.workspace.sh} (92%) rename tests/{posture.sh => source-mode.sh} (68%) diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 442b06f..7b81a7c 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -139,13 +139,13 @@ jobs: - name: Run tests/runtime-guard.sh run: ./tests/runtime-guard.sh - posture: - name: installed-source posture (#314) + source-mode: + name: installed-source mode (#314, #324) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Run tests/posture.sh - run: ./tests/posture.sh + - name: Run tests/source-mode.sh + run: ./tests/source-mode.sh service-migration: name: root -> non-root service identity migration (#93) diff --git a/README.md b/README.md index 43add08..51bbbc6 100644 --- a/README.md +++ b/README.md @@ -159,9 +159,9 @@ operator-entrypoints/wp-coding-agents-setup/setup.md | Flag | Description | | --- | --- | | `--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. | +| `--source-mode ` | `workspace` (default) or `owned`. See [Source Mode](#source-mode). `--posture` is a deprecated alias. | +| `--owned-source ` | wp-content path the site owns and may edit under `--source-mode owned`. Repeatable. | +| `--owned-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. | @@ -185,14 +185,18 @@ operator-entrypoints/wp-coding-agents-setup/setup.md Run `./setup.sh --help` for the complete setup surface. -## Install Posture +## Source Mode -Posture is the agent's relationship to the installed WordPress source. It is a -declared intent rather than a detected fact, and it is the single input that -decides the plugin set, every runtime permission surface, and the AGENTS.md -guidance the agent reads. +Source mode is where the agent's code changes land. It is a declared intent +rather than a detected fact, and it is the single input that decides the plugin +set, every runtime permission surface, and the AGENTS.md guidance the agent +reads. -| | `engineering` (default) | `managed` | +These are two shapes, not two levels. Neither is "more access" than the other — +`workspace` is in fact *stricter* on live source, since the installed tree is +read-only reference there. What it buys is git and review, not latitude. + +| | `workspace` (default) | `owned` | | --- | --- | --- | | 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 | @@ -203,16 +207,16 @@ guidance the agent reads. | 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 — +Both modes 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 +**Workspace** 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. -**Managed** is for managed agentic hosting, where a non-technical owner should +**Owned** is for managed agentic hosting, where a non-technical owner should never have to deal with pull requests. The agent edits the site's own theme and 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 @@ -223,9 +227,9 @@ 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 \ - --managed-source wp-content/themes/acme \ - --managed-source wp-content/plugins/acme-core +./setup.sh --source-mode owned \ + --owned-source wp-content/themes/acme \ + --owned-source wp-content/plugins/acme-core ``` **The declared set must match what the operator's capture records** — a path @@ -240,11 +244,11 @@ 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 +--owned-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 +explicitly. Conflating them with `--owned-source` would have AGENTS.md promise that the work is recorded when it is not. ### Log access @@ -260,7 +264,7 @@ writable): ``` A single source of truth (`lib/source-policy.sh`) derives the runtime -permissions and the generated guidance from the posture and these +permissions and the generated guidance from the source mode and these declarations, so the prose cannot tell the agent to do something the permissions then block. @@ -268,11 +272,11 @@ Managed is currently supported on the `opencode` runtime only. OpenCode evaluates permissions with `findLast` over rules in config key order, so a narrow allow written after a broad deny wins. Claude Code treats deny as absolute and Codex has no documented precedence for overlapping filesystem -entries, so both refuse managed posture rather than emit a permission set whose +entries, so both refuse owned mode rather than emit a permission set whose behavior on a production site is unverified. -The chosen posture is recorded on the install, so `./upgrade.sh` converges to it -without repeating the flag. Pass `--posture` to either script to change it. +The chosen mode is recorded on the install, so `./upgrade.sh` converges to it +without repeating the flag. Pass `--source-mode` to either script to change it. On VPS hosts with multiple Kimaki services, setup and upgrade select the unit whose `WorkingDirectory=` exactly matches the WordPress site path. Ambiguous or diff --git a/guidance/_dispatch.sh b/guidance/_dispatch.sh index 6505c22..03abff6 100644 --- a/guidance/_dispatch.sh +++ b/guidance/_dispatch.sh @@ -12,28 +12,28 @@ # renders marker-delimited PHP blocks, and rewrites them idempotently. It used # to also carry the CONTENT — the actual prose for every section — as heredocs # inside sync functions. That coupling meant every new section, and every -# posture variant of an existing section, grew the same file. This directory is +# source-mode variant of an existing section, grew the same file. This directory is # the content half; lib/agents-md-guidance.sh no longer knows what WordPress or # Homeboy are. # -# POSTURE VARIANTS +# SOURCE_MODE VARIANTS # -# A section whose prose depends on the install posture (see lib/source-policy.sh) -# ships one file per posture: +# A section whose prose depends on the install source mode (see lib/source-policy.sh) +# ships one file per mode: # # guidance/wordpress-source.engineering.sh # guidance/wordpress-source.managed.sh # -# Resolution for section is `..sh` when present, else -# `.sh`. Posture-neutral sections ship a single `.sh` and are unaffected. +# Resolution for section is `..sh` when present, else +# `.sh`. Mode-neutral sections ship a single `.sh` and are unaffected. # Two files that differ wholesale beat one file with a conditional wrapped -# around a heredoc: the diff of a posture is the file, and neither variant can +# around a heredoc: the diff of a mode is the file, and neither variant can # quietly inherit a clause meant for the other. # # HOOK CONTRACT (functions namespaced guidance_* inside each unit file) # # Mandatory: -# guidance_id — SectionRegistry section id (stable across postures) +# guidance_id — SectionRegistry section id (stable across modes) # guidance_priority — integer sort key within AGENTS.md # guidance_label — human label recorded in section metadata # guidance_render — emit the section markdown on stdout @@ -60,8 +60,8 @@ fi # guidance_names — every discoverable section id, one per line, de-duplicated. # # Discovery: any guidance/*.sh whose basename does not start with `_`. A -# `..sh` filename contributes the id once, no matter how many -# posture variants exist. +# `..sh` filename contributes the id once, no matter how many +# mode variants exist. guidance_names() { local f base id seen="" for f in "$GUIDANCE_DIR"/*.sh; do @@ -79,15 +79,15 @@ guidance_names() { done } -# guidance_file — absolute path to the unit for the active posture. +# guidance_file — absolute path to the unit for the active source mode. # -# Prefers the posture-specific variant, falls back to the neutral file. +# Prefers the mode-specific variant, falls back to the neutral file. guidance_file() { local id="$1" - local posture="${POSTURE:-engineering}" + local mode="${SOURCE_MODE:-workspace}" - if [ -f "$GUIDANCE_DIR/${id}.${posture}.sh" ]; then - printf '%s' "$GUIDANCE_DIR/${id}.${posture}.sh" + if [ -f "$GUIDANCE_DIR/${id}.${mode}.sh" ]; then + printf '%s' "$GUIDANCE_DIR/${id}.${mode}.sh" return 0 fi @@ -178,7 +178,7 @@ guidance_call() { ) } -# guidance_sync_all — sync every discovered section for the active posture. +# guidance_sync_all — sync every discovered section for the active source mode. # # Called once from setup.sh and once from upgrade.sh. Individual units may also # be synced on their own (lib/homeboy.sh re-syncs the homeboy section after the diff --git a/guidance/abilities.sh b/guidance/abilities.sh index a1ab562..2951a99 100644 --- a/guidance/abilities.sh +++ b/guidance/abilities.sh @@ -1,8 +1,8 @@ #!/bin/bash # guidance/abilities.sh — WordPress Abilities discovery guidance. # -# Posture-neutral: abilities are the tool surface in both engineering and -# managed installs, and the routing advice does not change with the agent's +# Mode-neutral: abilities are the tool surface in both workspace and +# owned-mode installs, and the routing advice does not change with the agent's # relationship to installed source. guidance_id() { printf 'abilities'; } diff --git a/guidance/homeboy.sh b/guidance/homeboy.sh index f9dd7c0..568e949 100644 --- a/guidance/homeboy.sh +++ b/guidance/homeboy.sh @@ -2,7 +2,7 @@ # guidance/homeboy.sh — Homeboy orchestration routing guidance (issues #208, #254, #298). # # Strictly presence-gated on the optional homeboy binary, and additionally on -# postures that actually have a workspace: the routing advice below is about +# modes that actually have a workspace: the routing advice below is about # cooking tracked changes in managed worktrees, which is meaningless on a # managed-hosting install where the agent edits live source and never touches # git. diff --git a/guidance/wordpress-source.managed.sh b/guidance/wordpress-source.owned.sh similarity index 94% rename from guidance/wordpress-source.managed.sh rename to guidance/wordpress-source.owned.sh index 3515b6d..df3abe0 100644 --- a/guidance/wordpress-source.managed.sh +++ b/guidance/wordpress-source.owned.sh @@ -1,5 +1,5 @@ #!/bin/bash -# guidance/wordpress-source.managed.sh — installed WordPress source, managed posture. +# guidance/wordpress-source.owned.sh — installed WordPress source, owned mode. # # WHAT THIS SECTION IS FOR # @@ -34,7 +34,7 @@ guidance_priority() { printf '1'; } guidance_label() { printf 'WordPress Source'; } 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 owned-source list is generated from the declared managed sources.'; } +guidance_conditions() { printf 'Registered on owned-mode installations; the owned-source list is generated from the declared owned sources.'; } guidance_render() { local owned writable diff --git a/guidance/wordpress-source.engineering.sh b/guidance/wordpress-source.workspace.sh similarity index 92% rename from guidance/wordpress-source.engineering.sh rename to guidance/wordpress-source.workspace.sh index 7de75c3..54262b9 100644 --- a/guidance/wordpress-source.engineering.sh +++ b/guidance/wordpress-source.workspace.sh @@ -1,5 +1,5 @@ #!/bin/bash -# guidance/wordpress-source.engineering.sh — installed WordPress source, engineering posture. +# guidance/wordpress-source.workspace.sh — installed WordPress source, workspace mode. # # 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 @@ -14,7 +14,7 @@ guidance_priority() { printf '1'; } guidance_label() { printf '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.'; } +guidance_conditions() { printf 'Registered by wp-coding-agents on workspace-mode installations.'; } guidance_render() { cat <<'MD' diff --git a/lib/agents-md-guidance.sh b/lib/agents-md-guidance.sh index 400936c..1c5eb79 100644 --- a/lib/agents-md-guidance.sh +++ b/lib/agents-md-guidance.sh @@ -12,7 +12,7 @@ # has no opinion about what any section says. The prose lives in guidance/*.sh, # one self-contained unit per section, discovered and dispatched by # guidance/_dispatch.sh — see that file for the hook contract and for how -# posture selects between section variants. +# the source mode selects between section variants. # # Resolved file: $SITE_PATH/wp-content/mu-plugins/wp-coding-agents-agents-md.php # diff --git a/lib/data-machine.sh b/lib/data-machine.sh index a57390f..be7b85d 100644 --- a/lib/data-machine.sh +++ b/lib/data-machine.sh @@ -33,7 +33,7 @@ install_data_machine() { echo -e "${BLUE}[dry-run]${NC} $WP_CMD config set DATAMACHINE_WORKSPACE_PATH $DM_WORKSPACE_DIR --type=constant" fi else - log "Skipping Data Machine Code (posture: ${POSTURE:-managed} — no workspace on this install)" + log "Skipping Data Machine Code (source mode: ${SOURCE_MODE:-owned} — no workspace on this install)" fi set_compose_agents_md_constant @@ -79,7 +79,7 @@ upgrade_data_machine_plugins() { if source_policy_workspace_enabled; then update_plugin_to_latest_tag data-machine-code https://github.com/Extra-Chill/data-machine-code.git else - log " Skipping Data Machine Code (posture: ${POSTURE:-managed})" + log " Skipping Data Machine Code (source mode: ${SOURCE_MODE:-owned})" fi # Backfill the AGENTS.md composition gate on existing installs (idempotent). diff --git a/lib/repair-opencode-json.py b/lib/repair-opencode-json.py index af6876a..97f4ab5 100755 --- a/lib/repair-opencode-json.py +++ b/lib/repair-opencode-json.py @@ -77,7 +77,7 @@ OBSOLETE_KIMAKI_PLUGIN_NAMES = {"homeboy-notification-context.ts"} DM_MEMORY_MARKER = "/datamachine-files/" # Every installed path wp-coding-agents manages, as ready-made edit patterns in -# canonical order. Denied under BOTH postures. +# canonical order. Denied under BOTH modes. # # Directories carry /**; root files are exact literals. That distinction is not # cosmetic: OpenCode's matcher turns `*` into `.*`, which spans slashes, so a @@ -108,7 +108,7 @@ "xmlrpc.php", "index.php", ) -DEFAULT_POSTURE = "engineering" +DEFAULT_SOURCE_MODE = "workspace" def expected_plugins( runtime: str, @@ -387,11 +387,22 @@ def apply_instruction_sync(data: dict, desired: List[str]) -> dict: return result +def canonical_source_mode(mode: str) -> str: + """Translate a pre-rename posture name to its source-mode equivalent. + + The shell does this in source_policy_canonical_mode; the reconciler is + reachable directly (and by an operator scripting it), so it has to agree. + A caller passing "managed" and silently getting the workspace ruleset would + produce an owned install with no editable source at all. + """ + return {"engineering": "workspace", "managed": "owned"}.get(mode, mode) + + def expected_edit_permission( data: dict, - posture: str = DEFAULT_POSTURE, - managed_sources: List[str] | None = None, - managed_writable: List[str] | None = None, + source_mode: str = DEFAULT_SOURCE_MODE, + owned_sources: List[str] | None = None, + owned_writable: List[str] | None = None, log_paths: List[str] | None = None, ) -> dict: """User rules, then managed denies, then the narrower allows. @@ -401,9 +412,9 @@ def expected_edit_permission( allows that carve exceptions out of them. Emitting the allows first would silently invert the policy. """ - is_managed = posture == "managed" - sources = list(managed_sources or []) if is_managed else [] - writable = list(managed_writable or []) if is_managed else [] + is_owned = canonical_source_mode(source_mode) == "owned" + sources = list(owned_sources or []) if is_owned else [] + writable = list(owned_writable or []) if is_owned else [] logs = list(log_paths or []) source_keys = [f"{path}/**" for path in sources] @@ -446,7 +457,7 @@ def expected_edit_permission( def _is_stale_managed_key(pattern: str) -> bool: """True for an owned-source allow this install no longer declares. - Without this a path dropped from --managed-source would keep its allow rule + Without this a path dropped from --owned-source would keep its allow rule forever, which is the drift the reconciler exists to prevent. """ return ( @@ -483,7 +494,7 @@ def expected_external_directory( rules = { pattern: action for pattern, action in current.items() - if pattern not in managed and not _is_managed_external_key(pattern) + if pattern not in managed and not _is_owned_external_key(pattern) } else: rules = {} @@ -492,7 +503,7 @@ def expected_external_directory( return rules -def _is_managed_external_key(pattern: str) -> bool: +def _is_owned_external_key(pattern: str) -> bool: """True for a grant wp-coding-agents previously wrote and no longer declares. Absolute paths are ours to manage here; anything relative was added by the @@ -539,9 +550,9 @@ def apply_external_directory( def check_edit_permission( data: dict, runtime: str, - posture: str = DEFAULT_POSTURE, - managed_sources: List[str] | None = None, - managed_writable: List[str] | None = None, + source_mode: str = DEFAULT_SOURCE_MODE, + owned_sources: List[str] | None = None, + owned_writable: List[str] | None = None, log_paths: List[str] | None = None, ) -> dict: if runtime != "opencode": @@ -549,7 +560,7 @@ def check_edit_permission( permission = data.get("permission", {}) current = permission.get("edit") if isinstance(permission, dict) else None - expected = expected_edit_permission(data, posture, managed_sources, managed_writable, log_paths) + expected = expected_edit_permission(data, source_mode, owned_sources, owned_writable, log_paths) return { "status": "ok" if current == expected else "needed", "expected": expected, @@ -558,12 +569,12 @@ def check_edit_permission( def apply_edit_permission( data: dict, - posture: str = DEFAULT_POSTURE, - managed_sources: List[str] | None = None, - managed_writable: List[str] | None = None, + source_mode: str = DEFAULT_SOURCE_MODE, + owned_sources: List[str] | None = None, + owned_writable: List[str] | None = None, log_paths: List[str] | None = None, ) -> None: - expected = expected_edit_permission(data, posture, managed_sources, managed_writable, log_paths) + expected = expected_edit_permission(data, source_mode, owned_sources, owned_writable, log_paths) permission = data.get("permission", {}) if isinstance(permission, str): permission = {"*": permission} @@ -587,23 +598,23 @@ def main() -> int: choices=["kimaki", "cc-connect", "telegram", "none"], ) parser.add_argument( - "--posture", - default=DEFAULT_POSTURE, - choices=["engineering", "managed"], - help="Installed-source posture for this install (see lib/source-policy.sh)", + "--source-mode", + default=DEFAULT_SOURCE_MODE, + choices=["workspace", "owned", "engineering", "managed"], + help="Installed-source mode for this install (see lib/source-policy.sh)", ) parser.add_argument( - "--managed-source", + "--owned-source", action="append", default=[], - dest="managed_sources", - help="wp-content path this site owns and may edit under managed posture. Repeatable.", + dest="owned_sources", + help="wp-content path this site owns and may edit under owned source mode. Repeatable.", ) parser.add_argument( - "--managed-writable", + "--owned-writable", action="append", default=[], - dest="managed_writable", + dest="owned_writable", help="Denied path this install explicitly re-opens for editing. Not captured. Repeatable.", ) parser.add_argument( @@ -687,7 +698,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, args.managed_writable, args.log_paths) + edit_permission_result = check_edit_permission(data, args.runtime, args.source_mode, args.owned_sources, args.owned_writable, args.log_paths) external_directory_result = check_external_directory(data, args.runtime, args.workspace_dir, args.log_paths) # --- Plugin array check --- @@ -821,7 +832,7 @@ def main() -> int: edit_permission_status = "ok" if has_edit_permission_drift: - apply_edit_permission(data, args.posture, args.managed_sources, args.managed_writable, args.log_paths) + apply_edit_permission(data, args.source_mode, args.owned_sources, args.owned_writable, args.log_paths) edit_permission_status = "synced" external_directory_status = "ok" if has_external_directory_drift: diff --git a/lib/runtime-guard.sh b/lib/runtime-guard.sh index a440c2c..13ccb33 100644 --- a/lib/runtime-guard.sh +++ b/lib/runtime-guard.sh @@ -58,15 +58,15 @@ runtime_guard_sync() { } # Engineering installs have a developer at the keyboard; the guard would be - # noise. Remove any guard left behind by a posture switch. - if ! source_policy_is_managed; then + # noise. Remove any guard left behind by a source-mode switch. + if ! source_policy_is_owned; then if [ -f "$file" ]; then if [ "${DRY_RUN:-false}" = true ]; then echo -e "${BLUE}[dry-run]${NC} Would remove runtime guard mu-plugin $file" return 0 fi rm -f "$file" - log " Removed runtime guard mu-plugin (posture is ${POSTURE:-engineering}): $file" + log " Removed runtime guard mu-plugin (source mode is ${SOURCE_MODE:-workspace}): $file" if [ -n "${UPDATED_ITEMS+x}" ]; then UPDATED_ITEMS+=("runtime guard removed") fi diff --git a/lib/service-migration.sh b/lib/service-migration.sh index 0dcc851..ad3600a 100644 --- a/lib/service-migration.sh +++ b/lib/service-migration.sh @@ -35,16 +35,16 @@ # that says "the agent's files" will, on somebody else's install, mean something # nobody intended). # -# The inventory is posture-aware, which makes it the file-axis counterpart to the +# The inventory is mode-aware, which makes it the file-axis counterpart to the # capability table in #327: # -# managed runtime state only. No dev toolchain, no forge credentials — -# a managed agent has no git and no GitHub in its world at all, +# owned runtime state only. No dev toolchain, no forge credentials — +# an owned-mode agent has no git and no GitHub in its world at all, # so migrating them would hand over reach it has no use for. -# engineering runtime state plus the dev toolchain and forge auth, because -# that posture's whole job is workspace/git/GitHub work. +# workspace runtime state plus the dev toolchain and forge auth, because +# that mode's whole job is workspace/git/GitHub work. # -# Neither posture migrates SSH keys, secret stores, or shell history. There is no +# Neither mode migrates SSH keys, secret stores, or shell history. There is no # flag to opt into that. An agent that needs to reach another host should be given # a scoped credential deliberately, not inherit the operator's. # @@ -58,11 +58,11 @@ # Public surface: # service_migration_runtime_paths # newline-separated, HOME-relative # service_migration_toolchain_paths # newline-separated, HOME-relative -# service_migration_inventory # the two above, per posture +# service_migration_inventory # the two above, per mode # service_migration_excluded_paths # documented never-migrate list # service_migration_is_excluded # 0 = must never be migrated # service_migration_preflight # fails closed, changes nothing -# service_migration_estimate_bytes +# service_migration_estimate_bytes # service_migration_run # the migration itself # # Honors DRY_RUN (logs intent, makes no changes). @@ -82,7 +82,7 @@ SERVICE_MIGRATION_DEFAULT_USER="opencode" # Runtime state: the agent's own brain. Without these the migrated service is # amnesiac — no sessions, no runtime auth, no CLI state. Migrated under every -# posture because they are what "the agent" IS. +# mode because they are what "the agent" IS. service_migration_runtime_paths() { cat <<'EOF' .kimaki @@ -94,9 +94,9 @@ service_migration_runtime_paths() { EOF } -# Dev toolchain and forge credentials. Engineering posture only: these exist to -# serve workspace/git/GitHub work, which is precisely what managed posture does -# not do. Handing a managed agent GitHub auth would widen its reach for no +# Dev toolchain and forge credentials. Workspace mode only: these exist to +# serve workspace/git/GitHub work, which is precisely what owned mode does +# not do. Handing an owned-mode agent GitHub auth would widen its reach for no # capability it is ever asked to exercise. service_migration_toolchain_paths() { cat <<'EOF' @@ -120,7 +120,7 @@ go EOF } -# Paths that must never migrate, whatever the posture. Enumerated so the +# Paths that must never migrate, whatever the mode. Enumerated so the # exclusion is reviewable and testable rather than implied by absence — an # inventory that merely forgets `.ssh` is one careless addition away from # shipping it. @@ -179,15 +179,23 @@ service_migration_is_excluded() { # exclusion rules: the escape hatch does not become the way SSH keys travel. SERVICE_MIGRATION_EXTRA_PATHS="${SERVICE_MIGRATION_EXTRA_PATHS:-}" -# The inventory for a posture, as newline-separated HOME-relative paths. +# The inventory for a mode, as newline-separated HOME-relative paths. # Excluded paths are filtered unconditionally, so neither a future edit to the # lists above nor an operator's --migrate-extra can leak a credential. service_migration_inventory() { - local posture="${1:-engineering}" rel + local mode="${1:-workspace}" rel + # Accept the pre-rename names. A caller passing `managed` and silently + # getting the workspace inventory would migrate a dev toolchain and GitHub + # credentials onto an owned-mode agent, which is the one thing the split + # exists to prevent. + case "$mode" in + managed) mode="owned" ;; + engineering) mode="workspace" ;; + esac { service_migration_runtime_paths - [ "$posture" != "managed" ] && service_migration_toolchain_paths + [ "$mode" != "owned" ] && service_migration_toolchain_paths [ -n "$SERVICE_MIGRATION_EXTRA_PATHS" ] && printf '%s\n' "$SERVICE_MIGRATION_EXTRA_PATHS" } | while IFS= read -r rel; do [ -n "$rel" ] || continue @@ -223,13 +231,13 @@ $rel" # Total bytes the inventory occupies under a given home. Used by preflight to # refuse a migration that would fill the disk partway through. service_migration_estimate_bytes() { - local home="$1" posture="${2:-engineering}" rel total=0 sz + local home="$1" mode="${2:-workspace}" rel total=0 sz while IFS= read -r rel; do [ -n "$rel" ] || continue [ -e "$home/$rel" ] || continue sz=$(du -sb "$home/$rel" 2>/dev/null | cut -f1) [ -n "$sz" ] && total=$((total + sz)) - done <<<"$(service_migration_inventory "$posture")" + done <<<"$(service_migration_inventory "$mode")" echo "$total" } @@ -251,9 +259,9 @@ service_migration_effective_uid() { # --migrate-user should say so, not send the operator off to re-run under sudo # only to be told the flag was wrong all along. # -# Args: +# Args: service_migration_preflight() { - local target_user="$1" old_home="$2" posture="${3:-engineering}" + local target_user="$1" old_home="$2" mode="${3:-workspace}" if [ "${LOCAL_MODE:-false}" = true ]; then error "Service identity migration is not applicable to a local install (no systemd, no service user)." @@ -305,7 +313,7 @@ service_migration_preflight() { if [ -e "$new_home/$rel" ]; then error "Target home '$new_home' already contains '$rel'. Refusing to merge runtime state — inspect and remove it, or choose another target user." fi - done <<<"$(service_migration_inventory "$posture")" + done <<<"$(service_migration_inventory "$mode")" fi # Same-filesystem moves are renames and need no headroom; a cross-filesystem @@ -314,7 +322,7 @@ service_migration_preflight() { old_fs=$(df -P "$old_home" 2>/dev/null | awk 'NR==2 {print $1}') new_fs=$(df -P "$(dirname "$new_home")" 2>/dev/null | awk 'NR==2 {print $1}') if [ -n "$old_fs" ] && [ "$old_fs" != "$new_fs" ]; then - need=$(service_migration_estimate_bytes "$old_home" "$posture") + need=$(service_migration_estimate_bytes "$old_home" "$mode") avail=$(df -PB1 "$(dirname "$new_home")" 2>/dev/null | awk 'NR==2 {print $4}') if [ -n "$avail" ] && [ "$need" -gt 0 ] && [ "$avail" -lt "$need" ]; then error "Not enough space to migrate: need $((need / 1024 / 1024)) MiB at '$new_home', $((avail / 1024 / 1024)) MiB available." @@ -466,8 +474,8 @@ service_migration_reclaim_site() { fi } -# The agent's code workspace, when one exists (engineering posture only — -# managed installs have no workspace by definition). +# The agent's code workspace, when one exists (workspace mode only — +# owned-mode installs have no workspace by definition). service_migration_reclaim_workspace() { local user="$1" workspace="$2" @@ -481,20 +489,20 @@ service_migration_reclaim_workspace() { # Run the migration. Assumes service_migration_preflight has already passed. # -# Args: +# Args: # # On success, sets SERVICE_USER / SERVICE_HOME / KIMAKI_DATA_DIR / RUN_AS_ROOT / # SERVICE_USER_FORCED for the caller so the normal upgrade phases re-render every # unit against the new identity. service_migration_run() { - local target_user="$1" old_home="$2" posture="${3:-engineering}" + local target_user="$1" old_home="$2" mode="${3:-workspace}" local new_home rel new_home=$(service_migration_target_home "$target_user") log "Migrating service identity: root -> $target_user" log " State home: $old_home -> $new_home" - log " Posture: $posture" + log " Source mode: $mode" if ! id -u "$target_user" >/dev/null 2>&1 || [ "${DRY_RUN:-false}" = true ]; then log " Creating service user '$target_user'..." @@ -517,12 +525,12 @@ service_migration_run() { log " $rel" service_migration_move_path "$rel" "$old_home" "$new_home" "$target_user" fi - done <<<"$(service_migration_inventory "$posture")" + done <<<"$(service_migration_inventory "$mode")" log " Reclaiming WordPress file ownership..." service_migration_reclaim_site "$target_user" "${SITE_PATH:-}" - if [ "$posture" != "managed" ]; then + if [ "$mode" != "owned" ]; then log " Reclaiming code workspace..." service_migration_reclaim_workspace "$target_user" "${DM_WORKSPACE_DIR:-}" fi diff --git a/lib/source-policy.sh b/lib/source-policy.sh index fce5fdf..5e9d8a0 100644 --- a/lib/source-policy.sh +++ b/lib/source-policy.sh @@ -1,5 +1,5 @@ #!/bin/bash -# lib/source-policy.sh — installed-WordPress-source policy, derived from posture. +# lib/source-policy.sh — installed-WordPress-source policy, derived from the source mode. # # wp-coding-agents installs two fundamentally different kinds of agent: # @@ -27,33 +27,59 @@ # # Duplicated policy drifts, and when the prose and the permissions disagree the # agent is told to do something it is then blocked from doing. This module is -# the single answer all of them derive from, so posture cannot be half-applied. +# the single answer all of them derive from, so the mode cannot be half-applied. # # Public surface: -# source_policy_resolve_posture # sets POSTURE (flag -> recorded -> default) -# source_policy_record_posture # persists POSTURE for later upgrades -# source_policy_is_valid +# source_policy_resolve_mode # sets SOURCE_MODE (flag -> recorded -> default) +# source_policy_record_mode # persists SOURCE_MODE for later upgrades +# source_policy_is_valid # source_policy_read_only_roots # newline-separated, ordered # source_policy_editable_roots # newline-separated, ordered # source_policy_workspace_enabled # 0 = workspace/git/GitHub apply # # Honors DRY_RUN (logs intent, makes no changes). -# The wp option wp-coding-agents records the chosen posture in. Mirrors the +# The wp option wp-coding-agents records the chosen source mode in. Mirrors the # existing `datamachine_code_homeboy_available` pattern: a setup-time fact that # upgrade.sh has to be able to rediscover without asking again. -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" +SOURCE_POLICY_OPTION="wp_coding_agents_source_mode" +SOURCE_POLICY_DEFAULT_MODE="workspace" +# Newline-separated wp-content paths the site owns under owned mode. +SOURCE_POLICY_OWNED_OPTION="wp_coding_agents_owned_sources" # Paths that are editable but NOT captured. Distinct from owned sources on -# purpose: --managed-source means "editable AND recorded by the operator's +# purpose: --owned-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" +SOURCE_POLICY_WRITABLE_OPTION="wp_coding_agents_owned_writable" + +# The option names these replace. Every existing install has its state under +# these keys, so the rename is only safe if reading transparently falls back and +# writing migrates. Ordered to match the three constants above. +SOURCE_POLICY_LEGACY_OPTION="wp_coding_agents_posture" +SOURCE_POLICY_LEGACY_OWNED_OPTION="wp_coding_agents_managed_sources" +SOURCE_POLICY_LEGACY_WRITABLE_OPTION="wp_coding_agents_managed_writable" + +# Read an option, falling back to its pre-rename name. +# +# Not a one-shot migration on upgrade: an operator can run a NEWER upgrade.sh +# against an install whose options were written by an older one at any time, and +# a `wp option get` returning empty is indistinguishable from "recorded as +# empty". Reading through this keeps both true regardless of the order anyone +# runs anything in. +_source_policy_option_get() { + local key="$1" legacy="$2" value="" + + value="$(wp_cmd option get "$key" 2>/dev/null || true)" + if [ -n "$(printf '%s' "$value" | tr -d '[:space:]')" ]; then + printf '%s' "$value" + return 0 + fi + + wp_cmd option get "$legacy" 2>/dev/null || true +} # 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 @@ -125,46 +151,67 @@ _source_policy_all_root_paths() { source_policy_is_valid() { case "${1:-}" in - engineering|managed) return 0 ;; + workspace|owned) return 0 ;; *) return 1 ;; esac } -# Resolve the active posture into the POSTURE global. +# Translate a legacy posture name to its source-mode equivalent. +# +# The old names read as a scale — as though `engineering` were the unrestricted +# one and `managed` the safe one. It is not a scale, and it is backwards: +# `engineering` is strictly MORE restricted on live source, since installed +# source is read-only reference there. What it buys is git and review, not +# latitude. The new names say where a change LANDS, which is the actual +# difference, and neither implies rank. +# +# Accepted anywhere a mode is: recorded option values written by older installs, +# and the --posture flag. Returns the input unchanged when it is not a legacy +# name, so this is safe to run over an already-migrated value. +source_policy_canonical_mode() { + case "${1:-}" in + engineering) echo "workspace" ;; + managed) echo "owned" ;; + *) echo "${1:-}" ;; + esac +} + +# Resolve the active source mode into the SOURCE_MODE global. # -# Precedence: explicit --posture flag (POSTURE_EXPLICIT=true) > posture recorded -# on the install at setup time > engineering. The recorded value is what lets -# `upgrade.sh` converge a managed box without the operator repeating the flag — -# and, critically, without silently reverting it to engineering. -source_policy_resolve_posture() { - if [ "${POSTURE_EXPLICIT:-false}" = true ]; then - if ! source_policy_is_valid "${POSTURE:-}"; then - error "Unknown --posture '${POSTURE:-}'. Supported: engineering, managed" +# Precedence: explicit --source-mode flag (SOURCE_MODE_EXPLICIT=true) > mode +# recorded on the install at setup time > workspace. The recorded value is what +# lets `upgrade.sh` converge an owned-mode box without the operator repeating +# the flag — and, critically, without silently reverting it to workspace. +source_policy_resolve_mode() { + if [ "${SOURCE_MODE_EXPLICIT:-false}" = true ]; then + SOURCE_MODE="$(source_policy_canonical_mode "${SOURCE_MODE:-}")" + if ! source_policy_is_valid "${SOURCE_MODE:-}"; then + error "Unknown --source-mode '${SOURCE_MODE:-}'. Supported: workspace, owned (legacy: engineering, managed)" fi - log "Posture: $POSTURE (explicit)" + log "Source mode: $SOURCE_MODE (explicit)" return 0 fi local recorded="" - recorded="$(source_policy_recorded_posture)" + recorded="$(source_policy_canonical_mode "$(source_policy_recorded_mode)")" if source_policy_is_valid "$recorded"; then - POSTURE="$recorded" - log "Posture: $POSTURE (recorded on this install)" + SOURCE_MODE="$recorded" + log "Source mode: $SOURCE_MODE (recorded on this install)" return 0 fi - POSTURE="$SOURCE_POLICY_DEFAULT_POSTURE" - log "Posture: $POSTURE (default)" + SOURCE_MODE="$SOURCE_POLICY_DEFAULT_MODE" + log "Source mode: $SOURCE_MODE (default)" } -# Read the posture recorded on this install, or '' when unavailable. +# Read the source mode recorded on this install, or '' when unavailable. # # Deliberately quiet: a fresh install, a dry run, or a site whose database is # not reachable yet must fall through to the default rather than fail. -source_policy_recorded_posture() { +source_policy_recorded_mode() { if [ "${DRY_RUN:-false}" = true ]; then - printf '%s' "${POSTURE:-}" + printf '%s' "${SOURCE_MODE:-}" return 0 fi @@ -172,15 +219,15 @@ source_policy_recorded_posture() { return 0 fi - wp_cmd option get "$SOURCE_POLICY_OPTION" 2>/dev/null | tr -d '[:space:]' || true + _source_policy_option_get "$SOURCE_POLICY_OPTION" "$SOURCE_POLICY_LEGACY_OPTION" | tr -d '[:space:]' } -# Persist the resolved posture so upgrade.sh can rediscover it. -source_policy_record_posture() { - local posture="${POSTURE:-$SOURCE_POLICY_DEFAULT_POSTURE}" +# Persist the resolved source mode so upgrade.sh can rediscover it. +source_policy_record_mode() { + local mode="${SOURCE_MODE:-$SOURCE_POLICY_DEFAULT_MODE}" if [ "${DRY_RUN:-false}" = true ]; then - echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_OPTION $posture" + echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_OPTION $mode" return 0 fi @@ -188,23 +235,29 @@ source_policy_record_posture() { return 0 fi - if [ "$(source_policy_recorded_posture)" = "$posture" ]; then + # Compare against the NEW key only. Comparing through the legacy-aware reader + # would see a pre-rename install as already correct — the canonical value of + # `engineering` IS `workspace` — and leave it recorded under the old key + # forever, so the migration would never actually happen. + local current="" + current="$(wp_cmd option get "$SOURCE_POLICY_OPTION" 2>/dev/null | tr -d '[:space:]' || true)" + if [ "$current" = "$mode" ]; then return 0 fi - if wp_cmd option update "$SOURCE_POLICY_OPTION" "$posture" >/dev/null 2>&1; then - log " Recorded install posture: $posture" + if wp_cmd option update "$SOURCE_POLICY_OPTION" "$mode" >/dev/null 2>&1; then + log " Recorded install source mode: $mode" if [ -n "${UPDATED_ITEMS+x}" ]; then - UPDATED_ITEMS+=("posture: $posture") + UPDATED_ITEMS+=("source mode: $mode") fi else - warn "Could not record install posture '$posture' — upgrades will fall back to $SOURCE_POLICY_DEFAULT_POSTURE" + warn "Could not record install source mode '$mode' — upgrades will fall back to $SOURCE_POLICY_DEFAULT_MODE" fi } -# Roots the agent must NOT edit, under EITHER posture. +# Roots the agent must NOT edit, under EITHER mode. # -# This is deliberately every root in both postures. `wp-content/plugins/` holds +# This is deliberately every root in both modes. `wp-content/plugins/` holds # WooCommerce, payment gateways, and the agent's own Data Machine runtime; # `wp-content/themes/` holds the stock bundled themes. None of those belong to # the site owner, none are captured by the operator's harvest, and editing them @@ -232,11 +285,11 @@ source_policy_read_only_roots() { # plugins a site "owns" by inspection, and guessing wrong on a production site # is not an acceptable default. source_policy_owned_sources() { - if ! source_policy_is_managed; then + if ! source_policy_is_owned; then return 0 fi - printf '%s\n' "${MANAGED_SOURCES:-}" | while IFS= read -r path; do + printf '%s\n' "${OWNED_SOURCES:-}" | while IFS= read -r path; do [ -n "$path" ] || continue printf '%s\n' "$path" done @@ -244,11 +297,11 @@ source_policy_owned_sources() { # Declared editable-but-not-captured paths. Empty unless managed. source_policy_writable_paths() { - if ! source_policy_is_managed; then + if ! source_policy_is_owned; then return 0 fi - printf '%s\n' "${MANAGED_WRITABLE:-}" | while IFS= read -r path; do + printf '%s\n' "${OWNED_WRITABLE:-}" | while IFS= read -r path; do [ -n "$path" ] || continue printf '%s\n' "$path" done @@ -256,23 +309,23 @@ source_policy_writable_paths() { # Declared read-only paths outside the site root. source_policy_log_paths() { - printf '%s\n' "${MANAGED_LOG_PATHS:-}" | while IFS= read -r path; do + printf '%s\n' "${SOURCE_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:-}")" + if [ "${SOURCE_LOG_PATHS_EXPLICIT:-false}" = true ]; then + SOURCE_LOG_PATHS="$(_source_policy_normalize_log_paths "${SOURCE_LOG_PATHS:-}")" else - MANAGED_LOG_PATHS="$(_source_policy_normalize_log_paths "$(source_policy_recorded_log_paths)")" + SOURCE_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:-}" + printf '%s' "${SOURCE_LOG_PATHS:-}" return 0 fi if [ -z "${SITE_PATH:-}" ] || [ ! -f "$SITE_PATH/wp-config.php" ]; then @@ -282,7 +335,7 @@ source_policy_recorded_log_paths() { } source_policy_record_log_paths() { - local paths="${MANAGED_LOG_PATHS:-}" + local paths="${SOURCE_LOG_PATHS:-}" if [ "${DRY_RUN:-false}" = true ]; then echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_LOG_OPTION '<${paths}>'" @@ -318,32 +371,32 @@ _source_policy_normalize_log_paths() { done } -source_policy_is_managed() { - [ "${POSTURE:-$SOURCE_POLICY_DEFAULT_POSTURE}" = managed ] +source_policy_is_owned() { + [ "${SOURCE_MODE:-$SOURCE_POLICY_DEFAULT_MODE}" = owned ] } -# Resolve the declared owned-source set into MANAGED_SOURCES. +# Resolve the declared owned-source set into OWNED_SOURCES. # -# Precedence mirrors posture: explicit --managed-source flags, then the set +# Precedence mirrors the source mode: explicit --owned-source flags, then the set # recorded on the install, then nothing. source_policy_resolve_owned_sources() { - if ! source_policy_is_managed; then - MANAGED_SOURCES="" + if ! source_policy_is_owned; then + OWNED_SOURCES="" return 0 fi - if [ "${MANAGED_SOURCES_EXPLICIT:-false}" = true ]; then - MANAGED_SOURCES="$(_source_policy_normalize_sources "${MANAGED_SOURCES:-}")" + if [ "${OWNED_SOURCES_EXPLICIT:-false}" = true ]; then + OWNED_SOURCES="$(_source_policy_normalize_sources "${OWNED_SOURCES:-}")" else - MANAGED_SOURCES="$(_source_policy_normalize_sources "$(source_policy_recorded_owned_sources)")" + OWNED_SOURCES="$(_source_policy_normalize_sources "$(source_policy_recorded_owned_sources)")" fi - if [ -z "$MANAGED_SOURCES" ]; then + if [ -z "$OWNED_SOURCES" ]; then # FAIL CLOSED. A managed install with nothing declared must not fall back # to opening wp-content wholesale; that is how a coding agent ends up with # write access to a payment gateway. Deny everything and make the operator # say what the site owns. - warn "Managed posture with no --managed-source declared: the agent will have NO editable source." + warn "Owned source mode with no --owned-source declared: the agent will have NO editable source." warn "Declare the site's own theme and plugins, for example:" warn " --managed-source wp-content/themes/ --managed-source wp-content/plugins/" warn "These must match what the operator's harvest captures." @@ -351,32 +404,32 @@ source_policy_resolve_owned_sources() { } source_policy_resolve_writable_paths() { - if ! source_policy_is_managed; then - MANAGED_WRITABLE="" + if ! source_policy_is_owned; then + OWNED_WRITABLE="" return 0 fi - if [ "${MANAGED_WRITABLE_EXPLICIT:-false}" = true ]; then - MANAGED_WRITABLE="$(_source_policy_normalize_writable "${MANAGED_WRITABLE:-}")" + if [ "${OWNED_WRITABLE_EXPLICIT:-false}" = true ]; then + OWNED_WRITABLE="$(_source_policy_normalize_writable "${OWNED_WRITABLE:-}")" else - MANAGED_WRITABLE="$(_source_policy_normalize_writable "$(source_policy_recorded_writable_paths)")" + OWNED_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:-}" + printf '%s' "${OWNED_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_option_get "$SOURCE_POLICY_WRITABLE_OPTION" "$SOURCE_POLICY_LEGACY_WRITABLE_OPTION" } source_policy_record_writable_paths() { - source_policy_is_managed || return 0 - local paths="${MANAGED_WRITABLE:-}" + source_policy_is_owned || return 0 + local paths="${OWNED_WRITABLE:-}" if [ "${DRY_RUN:-false}" = true ]; then echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_WRITABLE_OPTION '<${paths}>'" @@ -435,7 +488,7 @@ _source_policy_normalize_sources() { source_policy_recorded_owned_sources() { if [ "${DRY_RUN:-false}" = true ]; then - printf '%s' "${MANAGED_SOURCES:-}" + printf '%s' "${OWNED_SOURCES:-}" return 0 fi @@ -443,15 +496,15 @@ source_policy_recorded_owned_sources() { return 0 fi - wp_cmd option get "$SOURCE_POLICY_OWNED_OPTION" 2>/dev/null || true + _source_policy_option_get "$SOURCE_POLICY_OWNED_OPTION" "$SOURCE_POLICY_LEGACY_OWNED_OPTION" } source_policy_record_owned_sources() { - if ! source_policy_is_managed; then + if ! source_policy_is_owned; then return 0 fi - local sources="${MANAGED_SOURCES:-}" + local sources="${OWNED_SOURCES:-}" if [ "${DRY_RUN:-false}" = true ]; then echo -e "${BLUE}[dry-run]${NC} $WP_CMD option update $SOURCE_POLICY_OWNED_OPTION '<${sources}>'" @@ -487,24 +540,24 @@ source_policy_record_owned_sources() { # same shape would leave the owned paths unusable. Codex filesystem profiles # have no documented precedence for overlapping entries either. Rather than emit # a permission set whose behavior is unverified on a live production site, those -# runtimes refuse managed posture outright. -source_policy_runtime_supports_managed() { +# runtimes refuse owned source mode outright. +source_policy_runtime_supports_owned() { case "${1:-${RUNTIME:-}}" in opencode) return 0 ;; *) return 1 ;; esac } -source_policy_assert_runtime_supports_posture() { - if ! source_policy_is_managed; then +source_policy_assert_runtime_supports_mode() { + if ! source_policy_is_owned; then return 0 fi - if source_policy_runtime_supports_managed "${RUNTIME:-}"; then + if source_policy_runtime_supports_owned "${RUNTIME:-}"; then return 0 fi - error "Managed posture is not supported on the '${RUNTIME:-unknown}' runtime yet — only 'opencode' can express scoped source permissions safely. See lib/source-policy.sh." + error "Owned source mode is not supported on the '${RUNTIME:-unknown}' runtime yet — only 'opencode' can express scoped source permissions safely. See lib/source-policy.sh." } # Whether the Data Machine Code workspace (and therefore git/GitHub) is part of @@ -513,13 +566,13 @@ source_policy_assert_runtime_supports_posture() { # Consumed by the runtimes (workspace allow rules) and by lib/data-machine.sh # (whether data-machine-code is installed at all). Returns 0 for yes. source_policy_workspace_enabled() { - case "${POSTURE:-$SOURCE_POLICY_DEFAULT_POSTURE}" in - managed) return 1 ;; + case "${SOURCE_MODE:-$SOURCE_POLICY_DEFAULT_MODE}" in + owned) return 1 ;; *) return 0 ;; esac } -# The ordered edit ruleset for the active posture, as tab-separated +# The ordered edit ruleset for the active source mode, as tab-separated # `\t` lines. # # ORDER IS LOAD-BEARING. OpenCode resolves permissions with `findLast` over a @@ -528,7 +581,7 @@ source_policy_workspace_enabled() { # this order verbatim. # # Engineering emits three denies and nothing else, which is byte-identical to -# the pre-posture behavior. Managed emits the same three denies, then one allow +# the pre-source-mode behavior. Owned mode emits the same three denies, then one allow # per declared owned source. source_policy_edit_rules() { local path kind diff --git a/lib/summary.sh b/lib/summary.sh index 3223776..602196d 100644 --- a/lib/summary.sh +++ b/lib/summary.sh @@ -43,7 +43,7 @@ print_summary() { echo " Agent: $AGENT_SLUG" fi echo " Discover: $WP_CMD datamachine memory paths${AGENT_SLUG:+ --agent=$AGENT_SLUG} $WP_ROOT_FLAG" - echo " Posture: ${POSTURE:-engineering}" + echo " Source mode: ${SOURCE_MODE:-workspace}" if source_policy_workspace_enabled; then echo " Code tools: data-machine-code (workspace, GitHub, git)" echo " Workspace: $DM_WORKSPACE_DIR (created on first use)" diff --git a/runtimes/claude-code.sh b/runtimes/claude-code.sh index 8629dd0..4ef4201 100644 --- a/runtimes/claude-code.sh +++ b/runtimes/claude-code.sh @@ -194,7 +194,7 @@ runtime_install_hooks() { wp_prefix="studio wp" fi - # Workspace access only exists in postures that have a workspace. On a + # Workspace access only exists in modes that have a workspace. On a # managed install these rules would grant the agent an empty directory while # advertising a git workflow it has no part in. local workspace_allow_rules='[]' @@ -215,7 +215,7 @@ runtime_install_hooks() { # Every installed root is denied. Claude Code treats deny as absolute — an # allow never overrides it — so this runtime cannot express "deny the # directory, allow the site's own plugin inside it". That is why - # source_policy_assert_runtime_supports_posture refuses managed posture here + # source_policy_assert_runtime_supports_mode refuses owned source mode here # 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='[]' diff --git a/runtimes/codex.sh b/runtimes/codex.sh index 0c3d09d..9810a7c 100644 --- a/runtimes/codex.sh +++ b/runtimes/codex.sh @@ -107,8 +107,8 @@ runtime_generate_config() { mkdir -p "$config_dir" - # Only roots the active posture keeps read-only appear in the filesystem - # profile; anything the posture makes editable inherits the ":workspace" + # Only roots the active mode keeps read-only appear in the filesystem + # profile; anything the mode makes editable inherits the ":workspace" # default. See lib/source-policy.sh. local codex_read_roots="" local _root _kind diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index a03210a..e01928e 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -282,7 +282,7 @@ runtime_generate_config() { fi fi - # Permission: the installed-source mutation surface is posture-derived (see + # Permission: the installed-source mutation surface is source-mode-derived (see # lib/source-policy.sh). Engineering keeps every installed root outside # OpenCode's shared edit/write/apply_patch surface and grants the DM # workspace instead; managed inverts that for themes and plugins and grants @@ -290,7 +290,7 @@ runtime_generate_config() { 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 + # the DMC workspace; every mode 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="" @@ -410,7 +410,7 @@ _runtime_repair_opencode_json_additive() { --file "$SITE_PATH/opencode.json" \ --runtime opencode \ --chat-bridge "$BRIDGE_ARG" \ - --posture "${POSTURE:-engineering}" \ + --posture "${SOURCE_MODE:-workspace}" \ "${_managed_source_args[@]}" \ --kimaki-plugins-dir "$PLUGINS_DIR" \ "${claude_code_auth_args[@]}" \ diff --git a/setup.sh b/setup.sh index 98613e2..da48fec 100755 --- a/setup.sh +++ b/setup.sh @@ -72,14 +72,14 @@ ROTATE_AI_GATEWAY_TOKEN=false RUNTIME="" CHAT_BRIDGE_EXPLICIT=false HOMEBOY_MODE="auto" -POSTURE="" -POSTURE_EXPLICIT=false -MANAGED_SOURCES="" -MANAGED_SOURCES_EXPLICIT=false -MANAGED_WRITABLE="" -MANAGED_WRITABLE_EXPLICIT=false -MANAGED_LOG_PATHS="" -MANAGED_LOG_PATHS_EXPLICIT=false +SOURCE_MODE="" +SOURCE_MODE_EXPLICIT=false +OWNED_SOURCES="" +OWNED_SOURCES_EXPLICIT=false +OWNED_WRITABLE="" +OWNED_WRITABLE_EXPLICIT=false +SOURCE_LOG_PATHS="" +SOURCE_LOG_PATHS_EXPLICIT=false HOMEBOY_PROJECT_ID="${HOMEBOY_PROJECT_ID:-}" DETECTED_RUNTIMES=() IS_STUDIO=false @@ -202,24 +202,24 @@ while [[ $# -gt 0 ]]; do RUNTIME="$2" shift 2 ;; - --posture) - POSTURE="$2" - POSTURE_EXPLICIT=true + --source-mode|--posture) + SOURCE_MODE="$2" + SOURCE_MODE_EXPLICIT=true shift 2 ;; - --managed-source) - MANAGED_SOURCES="${MANAGED_SOURCES}${MANAGED_SOURCES:+ }$2" - MANAGED_SOURCES_EXPLICIT=true + --owned-source|--managed-source) + OWNED_SOURCES="${OWNED_SOURCES}${OWNED_SOURCES:+ }$2" + OWNED_SOURCES_EXPLICIT=true shift 2 ;; - --managed-writable) - MANAGED_WRITABLE="${MANAGED_WRITABLE}${MANAGED_WRITABLE:+ }$2" - MANAGED_WRITABLE_EXPLICIT=true + --owned-writable|--managed-writable) + OWNED_WRITABLE="${OWNED_WRITABLE}${OWNED_WRITABLE:+ }$2" + OWNED_WRITABLE_EXPLICIT=true shift 2 ;; --log-path) - MANAGED_LOG_PATHS="${MANAGED_LOG_PATHS}${MANAGED_LOG_PATHS:+ }$2" - MANAGED_LOG_PATHS_EXPLICIT=true + SOURCE_LOG_PATHS="${SOURCE_LOG_PATHS}${SOURCE_LOG_PATHS:+ }$2" + SOURCE_LOG_PATHS_EXPLICIT=true shift 2 ;; --agent-slug) @@ -280,19 +280,23 @@ OPTIONS: WordPress install (Studio, MAMP, manual, etc.) --runtime Coding agent runtime (auto-detected if omitted) Available: ${AVAILABLE_RUNTIMES[*]} - --posture Agent's relationship to installed WordPress source. - engineering (default): source is read-only reference and - all code changes go through a Data Machine Code - workspace, git, and GitHub. - managed: the agent edits the live theme and plugins in - place; no workspace, no git, no GitHub, and - data-machine-code is not installed. For managed agentic - hosting where changes are captured out-of-band. + --source-mode + Where the agent's code changes land. These are two shapes, + not two levels — neither is "more access" than the other. + workspace (default): installed source is read-only + reference and every change goes through a Data Machine + Code workspace, git, and GitHub. Recorded by review. + owned: the agent edits the site's own declared components + in place; no workspace, no git, no GitHub, and + data-machine-code is not installed. Recorded by the + operator's out-of-band capture. For managed agentic + hosting. Recorded on the install so upgrades converge without - repeating the flag. - --managed-source + repeating the flag. (--posture is accepted as a + deprecated alias; engineering=workspace, managed=owned.) + --owned-source wp-content path this site owns and the agent may edit - under --posture managed. Repeatable. Must be a plugin or + under --source-mode owned. Repeatable. Must be a plugin or theme directory, e.g. wp-content/themes/acme or wp-content/plugins/acme-core. Everything not declared stays read-only, including @@ -456,13 +460,13 @@ fi detect_environment -# Posture must resolve BEFORE anything that enforces it: the plugin set, the +# The source mode must resolve BEFORE anything that enforces it: the plugin set, the # runtime permission surfaces, and the AGENTS.md guidance all derive from it. -source_policy_resolve_posture +source_policy_resolve_mode source_policy_resolve_owned_sources source_policy_resolve_writable_paths source_policy_resolve_log_paths -source_policy_assert_runtime_supports_posture +source_policy_assert_runtime_supports_mode if [ "$INSTALL_CHAT" = true ] && [ "$CHAT_BRIDGE" = "kimaki" ] && [ "$LOCAL_MODE" = false ]; then bridge_load kimaki @@ -495,7 +499,7 @@ if [ "$RUNTIME_ONLY" != true ]; then setup_service_permissions fi -source_policy_record_posture +source_policy_record_mode source_policy_record_owned_sources source_policy_record_writable_paths source_policy_record_log_paths diff --git a/tests/agents-md-guidance.sh b/tests/agents-md-guidance.sh index 960829a..f4a58b9 100755 --- a/tests/agents-md-guidance.sh +++ b/tests/agents-md-guidance.sh @@ -20,7 +20,7 @@ source "$SCRIPT_DIR/lib/source-policy.sh" source "$SCRIPT_DIR/lib/agents-md-guidance.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/guidance/_dispatch.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" UPDATED_ITEMS=() VERBOSE=false diff --git a/tests/claude-code-permissions.sh b/tests/claude-code-permissions.sh index 22e92ad..5d8def6 100755 --- a/tests/claude-code-permissions.sh +++ b/tests/claude-code-permissions.sh @@ -32,7 +32,7 @@ warn() { printf '%s\n' "$*" >&2; } source "$SCRIPT_DIR/lib/common.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/source-policy.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" source "$SCRIPT_DIR/runtimes/claude-code.sh" runtime_install_hooks diff --git a/tests/codex-permissions.sh b/tests/codex-permissions.sh index fb87e07..908f997 100755 --- a/tests/codex-permissions.sh +++ b/tests/codex-permissions.sh @@ -26,7 +26,7 @@ warn() { printf '%s\n' "$*" >&2; } source "$SCRIPT_DIR/lib/common.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/source-policy.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" source "$SCRIPT_DIR/runtimes/codex.sh" runtime_generate_config diff --git a/tests/codex-runtime.sh b/tests/codex-runtime.sh index 49ae274..0c64697 100755 --- a/tests/codex-runtime.sh +++ b/tests/codex-runtime.sh @@ -30,7 +30,7 @@ printf 'memory context sentinel\n' > "$SITE_PATH/wp-content/uploads/datamachine- source "$SCRIPT_DIR/lib/common.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/source-policy.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/runtime-signature.sh" # shellcheck disable=SC1091 diff --git a/tests/homeboy-agents-md.sh b/tests/homeboy-agents-md.sh index 7ef5dfd..9224390 100755 --- a/tests/homeboy-agents-md.sh +++ b/tests/homeboy-agents-md.sh @@ -21,7 +21,7 @@ source "$SCRIPT_DIR/lib/source-policy.sh" source "$SCRIPT_DIR/lib/agents-md-guidance.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/guidance/_dispatch.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" UPDATED_ITEMS=() log() { :; } diff --git a/tests/opencode-local-plugin-path.sh b/tests/opencode-local-plugin-path.sh index 47740d5..c0dda0c 100755 --- a/tests/opencode-local-plugin-path.sh +++ b/tests/opencode-local-plugin-path.sh @@ -31,7 +31,7 @@ warn() { printf '%s\n' "$*" >&2; } source "$SCRIPT_DIR/lib/common.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/source-policy.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" # shellcheck disable=SC1091 source "$SCRIPT_DIR/runtimes/opencode.sh" diff --git a/tests/runtime-guard.sh b/tests/runtime-guard.sh index 5413941..fe4545c 100755 --- a/tests/runtime-guard.sh +++ b/tests/runtime-guard.sh @@ -34,22 +34,22 @@ wp_cmd() { printf 'data-machine/data-machine.php\ndata-machine-business/data-mac GUARD="$SITE_PATH/wp-content/mu-plugins/wp-coding-agents-runtime-guard.php" -echo "==> the guard is posture-scoped" -POSTURE=engineering runtime_guard_sync +echo "==> the guard is source-mode-scoped" +SOURCE_MODE=workspace runtime_guard_sync [ ! -f "$GUARD" ]; check $? "engineering installs no guard" -POSTURE=managed runtime_guard_sync +SOURCE_MODE=owned runtime_guard_sync [ -f "$GUARD" ]; check $? "managed installs the guard" php -l "$GUARD" >/dev/null 2>&1; check $? "generated guard parses" grep -q "data-machine-business/data-machine-business.php" "$GUARD" check $? "companion plugins are discovered, not assumed" -# A posture switch must clean up, or an engineering install keeps a guard +# A source-mode switch must clean up, or a workspace install keeps a guard # nothing maintains. -POSTURE=engineering runtime_guard_sync +SOURCE_MODE=workspace runtime_guard_sync [ ! -f "$GUARD" ]; check $? "switching to engineering removes the guard" -POSTURE=managed runtime_guard_sync >/dev/null +SOURCE_MODE=owned runtime_guard_sync >/dev/null echo "==> behaviour: links hidden, request refused" php -r ' diff --git a/tests/service-migration.sh b/tests/service-migration.sh index 7286a32..87438fc 100755 --- a/tests/service-migration.sh +++ b/tests/service-migration.sh @@ -106,22 +106,22 @@ assert_excluded "opencode-auth-backup.json" assert_excluded "some-service.pem" assert_excluded "deploy.key" -# And the inventories themselves must be clean under BOTH postures, which is +# And the inventories themselves must be clean under BOTH modes, which is # the assertion that survives someone adding an entry to the lists later. -for posture in engineering managed; do - inv="$(service_migration_inventory "$posture")" +for mode in workspace owned; do + inv="$(service_migration_inventory "$mode")" for bad in .ssh .secrets .pki .gnupg .aws .bash_history; do - refute_contains "$inv" "$bad" "$posture inventory omits $bad" + refute_contains "$inv" "$bad" "$mode inventory omits $bad" done done echo "" -echo "service-migration: posture shapes the inventory" +echo "service-migration: source mode shapes the inventory" -eng="$(service_migration_inventory engineering)" -man="$(service_migration_inventory managed)" +eng="$(service_migration_inventory workspace)" +man="$(service_migration_inventory owned)" -# Runtime state is what the agent IS — required under every posture, or the +# Runtime state is what the agent IS — required under every mode, or the # migrated service comes back with no sessions and no runtime auth. for p in .kimaki .config/opencode .local/share/opencode; do assert_contains "$eng" "$p" "engineering carries $p" @@ -163,11 +163,11 @@ echo "service-migration: --migrate-extra escape hatch" SERVICE_MIGRATION_EXTRA_PATHS="" service_migration_add_extra_path "homeboy-modules" service_migration_add_extra_path "go-sdk" - inv="$(service_migration_inventory engineering)" + inv="$(service_migration_inventory workspace)" assert_contains "$inv" "homeboy-modules" "extra path is carried" assert_contains "$inv" "go-sdk" "second extra path is carried" # And still absent from managed's runtime-only base set unless asked for. - assert_contains "$(service_migration_inventory managed)" "homeboy-modules" \ + assert_contains "$(service_migration_inventory owned)" "homeboy-modules" \ "extra paths apply under managed too" ) || FAILED=$((FAILED + 1)) diff --git a/tests/posture.sh b/tests/source-mode.sh similarity index 68% rename from tests/posture.sh rename to tests/source-mode.sh index 3931ae6..b18bf55 100755 --- a/tests/posture.sh +++ b/tests/source-mode.sh @@ -1,17 +1,17 @@ #!/bin/bash -# tests/posture.sh — installed-source posture regression coverage (#314). +# tests/source-mode.sh — installed-source mode regression coverage (#314, #324). # # Two properties matter here and neither is obvious from reading one file: # # 1. Engineering output is UNCHANGED. Every existing install is engineering, -# so a posture refactor that shifts a single glob is a silent permission +# so a source-mode refactor that shifts a single glob is a silent permission # change on every box in the fleet. # # 2. Managed output AGREES WITH ITSELF. The whole reason lib/source-policy.sh # exists is that the AGENTS.md prose and the enforced runtime permissions # used to be written independently and drifted: h44lacrosse.com shipped an # agent told to edit live theme and plugin files while its opencode.json -# denied exactly those two paths. A managed install that says "editable" +# denied exactly those two paths. An owned-mode install that says "editable" # in prose and "deny" in permissions is the bug, not a cosmetic mismatch. set -eu @@ -78,8 +78,8 @@ refute_rule() { if has_rule "$1"; then echo " FAIL $2 (unexpected $1)"; FAILED=$((FAILED + 1)); else echo " ok $2"; fi } -POSTURE=engineering -MANAGED_SOURCES=""; MANAGED_WRITABLE=""; MANAGED_LOG_PATHS="" +SOURCE_MODE=workspace +OWNED_SOURCES=""; OWNED_WRITABLE=""; SOURCE_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, @@ -95,21 +95,21 @@ 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" +refute_rule "wp-content/uploads=allow" "uploads are not an owned rule at all" source_policy_workspace_enabled \ && echo " ok engineering has a workspace" \ || { echo " FAIL engineering has a workspace"; FAILED=$((FAILED + 1)); } # Managed denies the same set and carves out only what was declared. -POSTURE=managed -MANAGED_SOURCES="wp-content/themes/acme +SOURCE_MODE=owned +OWNED_SOURCES="wp-content/themes/acme wp-content/plugins/acme-core" -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" +OWNED_WRITABLE=""; SOURCE_LOG_PATHS="" +check_rule "wp-content/plugins=deny" "owned mode still denies the plugins directory" +check_rule "wp-content/themes/acme=allow" "owned mode allows a declared owned theme" +check_rule "wp-content/plugins/acme-core=allow" "owned mode allows a declared owned plugin" +check_rule "wp-config.php=deny" "owned mode 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) @@ -126,21 +126,21 @@ source_policy_workspace_enabled \ || echo " ok managed has no workspace" # Fail closed. -MANAGED_SOURCES="" -refute_rule "wp-content/themes/acme=allow" "managed with nothing declared grants no edit access" +OWNED_SOURCES="" +refute_rule "wp-content/themes/acme=allow" "owned mode 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" +OWNED_SOURCES=""; OWNED_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" +OWNED_WRITABLE_EXPLICIT=true +OWNED_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="" +OWNED_WRITABLE_EXPLICIT=false; OWNED_WRITABLE="" # =========================================================================== echo "==> the agent can read the logs it needs to recover the site" @@ -150,29 +150,29 @@ echo "==> the agent can read the logs it needs to recover the site" # 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_LOG_PATHS_EXPLICIT=true +SOURCE_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=""; MANAGED_WRITABLE="" -refute_rule "wp-content/plugins=allow" "unknown posture never grants write" +SOURCE_MODE=nonsense +OWNED_SOURCES=""; OWNED_WRITABLE="" +refute_rule "wp-content/plugins=allow" "unknown mode never grants write" # =========================================================================== -echo "==> runtimes that cannot express scoped permissions refuse managed" +echo "==> runtimes that cannot express scoped permissions refuse owned mode" # =========================================================================== for rt in claude-code codex; do rc=0 - POSTURE=managed RUNTIME="$rt" \ - bash -c 'source lib/common.sh; source lib/source-policy.sh; error() { exit 3; }; source_policy_assert_runtime_supports_posture' \ + SOURCE_MODE=owned RUNTIME="$rt" \ + bash -c 'source lib/common.sh; source lib/source-policy.sh; error() { exit 3; }; source_policy_assert_runtime_supports_mode' \ >/dev/null 2>&1 || rc=$? if [ "$rc" -eq 3 ]; then - echo " ok $rt refuses managed posture" + echo " ok $rt refuses owned source mode" else echo " FAIL $rt should refuse managed posture (rc=$rc)" FAILED=$((FAILED + 1)) @@ -180,8 +180,8 @@ for rt in claude-code codex; do done rc=0 -POSTURE=managed RUNTIME=opencode \ - bash -c 'source lib/common.sh; source lib/source-policy.sh; error() { exit 3; }; source_policy_assert_runtime_supports_posture' \ +SOURCE_MODE=owned RUNTIME=opencode \ + bash -c 'source lib/common.sh; source lib/source-policy.sh; error() { exit 3; }; source_policy_assert_runtime_supports_mode' \ >/dev/null 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then echo " ok opencode accepts managed posture" @@ -190,14 +190,14 @@ else FAILED=$((FAILED + 1)) fi -MANAGED_LOG_PATHS_EXPLICIT=false; MANAGED_LOG_PATHS="" +SOURCE_LOG_PATHS_EXPLICIT=false; SOURCE_LOG_PATHS="" # =========================================================================== -echo "==> opencode.json permission surface follows posture" +echo "==> opencode.json permission surface follows the source mode" # =========================================================================== _opencode_config_for() { - local posture="$1" out="$2" sources="${3:-}" logs="${4:-}" + local mode="$1" out="$2" sources="${3:-}" logs="${4:-}" ( TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -211,10 +211,10 @@ _opencode_config_for() { export DM_AGENT_FILES="" export WITH_CLAUDE_CODE_AUTH=false RUNTIME="opencode" UPDATED_ITEMS=() - POSTURE="$posture" - MANAGED_SOURCES="$sources" - MANAGED_WRITABLE="" - MANAGED_LOG_PATHS="$logs" + SOURCE_MODE="$mode" + OWNED_SOURCES="$sources" + OWNED_WRITABLE="" + SOURCE_LOG_PATHS="$logs" # shellcheck disable=SC1091 source "$SCRIPT_DIR/runtimes/opencode.sh" runtime_generate_config @@ -224,39 +224,39 @@ _opencode_config_for() { 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 +_opencode_config_for workspace "$ENG_JSON" +_opencode_config_for owned "$MGD_JSON" "wp-content/themes/acme 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_contains "$ENG_EDIT" 'wp-admin/**' "workspace mode denies wp-admin" +assert_contains "$ENG_EDIT" 'wp-config.php' "workspace mode denies wp-config.php" +assert_contains "$ENG_EDIT" 'wp-content/mu-plugins/**' "workspace mode denies mu-plugins" +refute_contains "$ENG_EDIT" 'wp-content/uploads' "workspace mode 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" + "['deny']" "workspace mode 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" + "owned mode 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" + "True" "owned mode emits the broad deny before the narrower allow" assert_contains "$MGD_EDIT_JSON" '"/var/log/site": "deny"' \ "a readable log path is denied for editing as a literal" assert_contains "$MGD_EDIT_JSON" '"/var/log/site/**": "deny"' \ "a readable log path is denied for editing as a subtree" 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" + "yes" "workspace mode grants the workspace directory" 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)" +refute_contains "$MGD_EXT" 'workspace' "owned mode grants no workspace directory (there is none)" assert_contains "$MGD_EXT" '"/var/log/site/**": "allow"' \ "managed grants read on the declared log directory" # A log path may be a single FILE (/var/log/php-fpm.log). Appending /** alone @@ -269,7 +269,7 @@ echo "==> claude-code denies every installed root (managed is refused upstream)" # =========================================================================== _claude_settings_for() { - local posture="$1" out="$2" seed="${3:-}" + local mode="$1" out="$2" seed="${3:-}" ( TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -281,7 +281,7 @@ _claude_settings_for() { sed "s|SITE_PATH|$SITE_PATH|g" "$seed" > "$SITE_PATH/.claude/settings.json" fi UPDATED_ITEMS=() - POSTURE="$posture" + SOURCE_MODE="$mode" # shellcheck disable=SC1091 source "$SCRIPT_DIR/runtimes/claude-code.sh" runtime_install_hooks >/dev/null 2>&1 @@ -293,11 +293,11 @@ _claude_settings_for() { CC_ENG="$(mktemp)" _claude_settings_for engineering "$CC_ENG" assert_contains "$(cat "$CC_ENG")" '"Edit(SITE_PATH/wp-content/themes/**)"' \ - "engineering denies theme edits" + "workspace mode denies theme edits" assert_contains "$(cat "$CC_ENG")" '"Edit(SITE_PATH/wp-content/plugins/**)"' \ - "engineering denies plugin edits" + "workspace mode denies plugin edits" assert_contains "$(cat "$CC_ENG")" '"Bash(wp datamachine-code workspace:*)"' \ - "engineering allows the DMC workspace bash surface" + "workspace mode allows the DMC workspace bash surface" rm -f "$CC_ENG" # =========================================================================== @@ -305,7 +305,7 @@ echo "==> codex filesystem profile keeps every installed root read-only" # =========================================================================== _codex_config_for() { - local posture="$1" + local mode="$1" ( TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -315,7 +315,7 @@ _codex_config_for() { chmod +x "$TMP/bin/codex" export PATH="$TMP/bin:$PATH" UPDATED_ITEMS=() - POSTURE="$posture" + SOURCE_MODE="$mode" # shellcheck disable=SC1091 source "$SCRIPT_DIR/runtimes/codex.sh" runtime_generate_config >/dev/null 2>&1 @@ -337,17 +337,17 @@ source "$SCRIPT_DIR/lib/agents-md-guidance.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/guidance/_dispatch.sh" -POSTURE=engineering +SOURCE_MODE=workspace ENG_PROSE="$(guidance_call wordpress-source render)" assert_eq "$(guidance_call wordpress-source id)" "wordpress-source" \ "section id is stable across postures" -assert_contains "$ENG_PROSE" "read-only" "engineering prose says read-only" -assert_contains "$ENG_PROSE" "managed workspace" "engineering prose routes changes to the workspace" +assert_contains "$ENG_PROSE" "read-only" "workspace mode prose says read-only" +assert_contains "$ENG_PROSE" "managed workspace" "workspace mode prose routes changes to the workspace" -POSTURE=managed -MANAGED_SOURCES="wp-content/themes/acme +SOURCE_MODE=owned +OWNED_SOURCES="wp-content/themes/acme wp-content/plugins/acme-core" -MANAGED_WRITABLE="wp-config.php" +OWNED_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" @@ -394,26 +394,26 @@ for term in WooCommerce Stripe commerce payment money composer.lock package-lock done # Fail closed in prose too. -MANAGED_SOURCES=""; MANAGED_WRITABLE="" +OWNED_SOURCES=""; OWNED_WRITABLE="" NONE_PROSE="$(guidance_call wordpress-source render)" assert_contains "$NONE_PROSE" 'Nothing on this install is declared as editable' \ "managed prose with nothing declared says so explicitly" 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" +OWNED_SOURCES="wp-content/themes/acme" # Engineering keeps the same capability framing. -POSTURE=engineering +SOURCE_MODE=workspace ENG_PROSE2="$(guidance_call wordpress-source render)" assert_contains "$ENG_PROSE2" '`wp-admin/`' \ - "engineering prose also points at wp-admin" + "workspace mode prose also points at wp-admin" assert_contains "$ENG_PROSE2" 'ground truth' \ - "engineering prose frames installed source as authoritative" -POSTURE=managed + "workspace mode prose frames installed source as authoritative" +SOURCE_MODE=owned # 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 +SOURCE_MODE=owned if guidance_call homeboy applies; then echo " FAIL homeboy guidance must not apply under managed" FAILED=$((FAILED + 1)) @@ -422,10 +422,10 @@ else fi # =========================================================================== -echo "==> opencode.json reconciler honours posture" +echo "==> opencode.json reconciler honours the source mode" # =========================================================================== -# #316: the reconciler owned permission.edit only, so an engineering->managed +# #316: the reconciler owned permission.edit only, so a workspace->owned # upgrade kept a stale workspace grant and declared log paths never landed. # The upgrade path is the one every real install takes. EXT_OUT="$(python3 - <<'PYX' @@ -441,9 +441,9 @@ print(json.dumps(mod.expected_external_directory(data, "", ["/var/log/site", "/v PYX )" refute_contains "$EXT_OUT" 'datamachine/workspace' \ - "managed reconcile drops the stale workspace grant" + "owned reconcile drops the stale workspace grant" assert_contains "$EXT_OUT" '"./operator-added": "allow"' \ - "managed reconcile preserves operator-added external grants" + "owned reconcile preserves operator-added external grants" assert_contains "$EXT_OUT" '"/var/log/one.log": "allow"' \ "a log path naming a file is granted as a literal, not only as a subtree" @@ -458,7 +458,7 @@ mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) data = json.load(open(sys.argv[1])) # json.dumps preserves insertion order, which is the property under test. -print(json.dumps(mod.expected_edit_permission(data, "managed", ["wp-content/plugins/acme-core"]))) +print(json.dumps(mod.expected_edit_permission(data, "owned", ["wp-content/plugins/acme-core"]))) PYX )" assert_contains "$RECON_OUT" '"custom/**": "ask"' \ @@ -481,4 +481,103 @@ if [ "$FAILED" -ne 0 ]; then fi echo -echo "OK: all posture assertions passed" +echo "OK: all source-mode assertions passed" + +TMPD="$(mktemp -d)" +trap 'rm -rf "$TMPD"' EXIT + +# =========================================================================== +echo "==> the #324 rename carries existing installs across" +# =========================================================================== + +# Renaming is only safe if every install that recorded the old vocabulary keeps +# working without the operator touching anything. Two live installs recorded +# `wp_coding_agents_posture` before this change; an upgrade that read an empty +# new key and fell through to the default would silently revert a managed box +# to workspace mode and hand its agent a workspace it does not have. + +assert_eq "$(source_policy_canonical_mode engineering)" "workspace" "engineering -> workspace" +assert_eq "$(source_policy_canonical_mode managed)" "owned" "managed -> owned" +assert_eq "$(source_policy_canonical_mode workspace)" "workspace" "workspace passes through" +assert_eq "$(source_policy_canonical_mode owned)" "owned" "owned passes through" +assert_eq "$(source_policy_canonical_mode nonsense)" "nonsense" "unknown passes through to fail validation" +assert_eq "$(source_policy_canonical_mode '')" "" "empty passes through" + +# The deprecated flag spelling must resolve to the new value, not error. +for legacy_pair in "engineering workspace" "managed owned"; do + set -- $legacy_pair + got=$( + SOURCE_MODE="$1" SOURCE_MODE_EXPLICIT=true DRY_RUN=true + log() { :; }; error() { echo "ERROR: $*"; exit 1; } + source_policy_resolve_mode >/dev/null 2>&1 + printf '%s' "$SOURCE_MODE" + ) + assert_eq "$got" "$2" "--posture $1 resolves to $2" +done + +# A recorded legacy value must resolve the same way. This is the upgrade path +# every existing install takes. +for legacy_pair in "engineering workspace" "managed owned"; do + set -- $legacy_pair + got=$( + SOURCE_MODE_EXPLICIT=false DRY_RUN=false SITE_PATH="$TMPD/site" + mkdir -p "$SITE_PATH"; : > "$SITE_PATH/wp-config.php" + log() { :; }; error() { echo "ERROR: $*"; exit 1; } + # Legacy key populated, new key empty — the pre-rename install. + LEGACY="$1" + wp_cmd() { + case "$*" in + *"option get wp_coding_agents_source_mode"*) return 0 ;; + *"option get wp_coding_agents_posture"*) echo "$LEGACY" ;; + *) return 0 ;; + esac + } + source_policy_resolve_mode >/dev/null 2>&1 + printf '%s' "$SOURCE_MODE" + ) + assert_eq "$got" "$2" "recorded '$1' resolves to $2 on upgrade" +done + +# ...and the migration must actually WRITE the new key. Comparing through the +# legacy-aware reader would see a pre-rename install as already correct — the +# canonical value of `engineering` IS `workspace` — and leave it on the old key +# forever, so the rename would never complete. +# +# The call site redirects wp_cmd's output to /dev/null, so the stub records what +# it was asked to do in a file rather than on a stream nobody can see. +PROBE="$TMPD/option-writes" +: > "$PROBE" +( + SOURCE_MODE="workspace" DRY_RUN=false SITE_PATH="$TMPD/site2" + mkdir -p "$SITE_PATH"; : > "$SITE_PATH/wp-config.php" + log() { :; }; warn() { :; } + wp_cmd() { + case "$*" in + *"option get wp_coding_agents_source_mode"*) return 0 ;; + *"option get wp_coding_agents_posture"*) echo "engineering" ;; + *"option update wp_coding_agents_source_mode"*) + echo "$*" >> "$PROBE" ;; + *) return 0 ;; + esac + } + source_policy_record_mode >/dev/null 2>&1 +) +assert_contains "$(cat "$PROBE")" "option update wp_coding_agents_source_mode workspace" \ + "record_mode migrates a legacy install onto the new key" + +# And it must be idempotent: once the new key holds the value, no second write. +: > "$PROBE" +( + SOURCE_MODE="workspace" DRY_RUN=false SITE_PATH="$TMPD/site3" + mkdir -p "$SITE_PATH"; : > "$SITE_PATH/wp-config.php" + log() { :; }; warn() { :; } + wp_cmd() { + case "$*" in + *"option get wp_coding_agents_source_mode"*) echo "workspace" ;; + *"option update"*) echo "$*" >> "$PROBE" ;; + *) return 0 ;; + esac + } + source_policy_record_mode >/dev/null 2>&1 +) +assert_eq "$(cat "$PROBE")" "" "record_mode does not rewrite an already-migrated install" diff --git a/tests/worktree-context-projections.sh b/tests/worktree-context-projections.sh index b4807f7..f227199 100755 --- a/tests/worktree-context-projections.sh +++ b/tests/worktree-context-projections.sh @@ -10,7 +10,7 @@ export SITE_PATH="$TMP/site" DRY_RUN=false source "$SCRIPT_DIR/lib/common.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/source-policy.sh" -POSTURE="${POSTURE:-engineering}" +SOURCE_MODE="${SOURCE_MODE:-workspace}" source "$SCRIPT_DIR/lib/runtime-signature.sh" log() { :; } warn() { :; } diff --git a/upgrade.sh b/upgrade.sh index 08112e1..7a01605 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -104,14 +104,14 @@ WITH_AI_GATEWAY=false WITH_CLAUDE_CODE_AUTH=true ROTATE_AI_GATEWAY_TOKEN=false SHOW_HELP=false -POSTURE="" -POSTURE_EXPLICIT=false -MANAGED_SOURCES="" -MANAGED_SOURCES_EXPLICIT=false -MANAGED_WRITABLE="" -MANAGED_WRITABLE_EXPLICIT=false -MANAGED_LOG_PATHS="" -MANAGED_LOG_PATHS_EXPLICIT=false +SOURCE_MODE="" +SOURCE_MODE_EXPLICIT=false +OWNED_SOURCES="" +OWNED_SOURCES_EXPLICIT=false +OWNED_WRITABLE="" +OWNED_WRITABLE_EXPLICIT=false +SOURCE_LOG_PATHS="" +SOURCE_LOG_PATHS_EXPLICIT=false # Defaults setup.sh expects (detect.sh reads these) LOCAL_MODE=false @@ -156,10 +156,10 @@ while [[ $# -gt 0 ]]; do --ai-gateway-model) AI_GATEWAY_ROUTE_MODEL="$2"; shift 2 ;; --ai-gateway-opencode-model) AI_GATEWAY_MODEL_ID="$2"; shift 2 ;; --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 ;; + --source-mode|--posture) SOURCE_MODE="$2"; SOURCE_MODE_EXPLICIT=true; shift 2 ;; + --owned-source|--managed-source) OWNED_SOURCES="${OWNED_SOURCES}${OWNED_SOURCES:+ }$2"; OWNED_SOURCES_EXPLICIT=true; shift 2 ;; + --owned-writable|--managed-writable) OWNED_WRITABLE="${OWNED_WRITABLE}${OWNED_WRITABLE:+ }$2"; OWNED_WRITABLE_EXPLICIT=true; shift 2 ;; + --log-path) SOURCE_LOG_PATHS="${SOURCE_LOG_PATHS}${SOURCE_LOG_PATHS:+ }$2"; SOURCE_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 ;; @@ -204,11 +204,14 @@ USAGE: only adds missing managed entries, never removes user-added plugins. ./upgrade.sh --runtime Force runtime (auto-detected otherwise) - ./upgrade.sh --posture Force install posture: engineering | managed - (default: the posture recorded at setup time) - ./upgrade.sh --managed-source + ./upgrade.sh --source-mode + Where code changes land: workspace | owned + (default: the mode recorded at setup time). + Two shapes, not two levels. --posture is + accepted as a deprecated alias. + ./upgrade.sh --owned-source wp-content path this site owns and may edit under - managed posture. Repeatable. Replaces the + --source-mode owned. Repeatable. Replaces the recorded set when supplied. ./upgrade.sh --wp-path Override detected WordPress path ./upgrade.sh --agent-slug Override Data Machine agent slug @@ -258,9 +261,9 @@ SERVICE IDENTITY: the shipped inventory cannot know about. Credential-shaped paths are refused. - What migrates is an explicit, posture-aware allowlist: runtime state - under every posture, plus the dev toolchain and forge credentials under - engineering posture only. SSH keys, secret stores, and shell history are + What migrates is an explicit, mode-aware allowlist: runtime state + under every mode, plus the dev toolchain and forge credentials under + workspace mode only. SSH keys, secret stores, and shell history are never migrated — they stay behind in root-owned /root, out of the agent's reach. That is the point of the migration, not a side effect. @@ -380,16 +383,16 @@ source "$RUNTIME_FILE" # which the chat bridge detection below depends on to pick the right branch. detect_environment -# Posture drives the plugin set, every runtime permission surface, and the +# The source mode drives the plugin set, every runtime permission surface, and the # AGENTS.md guidance. Resolve it from the value recorded at setup time so an # upgrade converges a managed install instead of silently reverting it to # engineering; --posture overrides and re-records. -source_policy_resolve_posture +source_policy_resolve_mode 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_assert_runtime_supports_mode +source_policy_record_mode source_policy_record_owned_sources source_policy_record_writable_paths source_policy_record_log_paths @@ -479,8 +482,8 @@ if [ "$MIGRATE_NON_ROOT" = true ]; then log "Install already runs as '$INSTALLED_SERVICE_USER' — nothing to migrate." MIGRATE_NON_ROOT=false else - service_migration_preflight "$MIGRATE_TARGET_USER" "/root" "$POSTURE" - service_migration_run "$MIGRATE_TARGET_USER" "/root" "$POSTURE" + service_migration_preflight "$MIGRATE_TARGET_USER" "/root" "$SOURCE_MODE" + service_migration_run "$MIGRATE_TARGET_USER" "/root" "$SOURCE_MODE" UPDATED_ITEMS+=("Service identity migrated: root -> $SERVICE_USER") fi fi @@ -664,22 +667,22 @@ check_opencode_json_drift() { # Owned-source allow rules the reconciler must (re)write. Empty under # engineering, so the argument list is unchanged there. - local _managed_source_args=() + local _owned_source_args=() local _owned_path while IFS= read -r _owned_path; do [ -n "$_owned_path" ] || continue - _managed_source_args+=(--managed-source "$_owned_path") + _owned_source_args+=(--owned-source "$_owned_path") done < <(source_policy_owned_sources) while IFS= read -r _owned_path; do [ -n "$_owned_path" ] || continue - _managed_source_args+=(--managed-writable "$_owned_path") + _owned_source_args+=(--owned-writable "$_owned_path") done < <(source_policy_writable_paths) while IFS= read -r _owned_path; do [ -n "$_owned_path" ] || continue - _managed_source_args+=(--log-path "$_owned_path") + _owned_source_args+=(--log-path "$_owned_path") done < <(source_policy_log_paths) if source_policy_workspace_enabled; then - _managed_source_args+=(--workspace-dir "$DM_WORKSPACE_DIR") + _owned_source_args+=(--workspace-dir "$DM_WORKSPACE_DIR") fi if [ ! -f "$HELPER" ]; then warn "Phase 3b: $HELPER not found — skipping drift check" @@ -752,15 +755,15 @@ for item in data: log "Phase 3b: opencode.json $MODE_LABEL..." if [ "$DRY_RUN" = true ]; then - local managed_arg_display="" + local owned_arg_display="" if [ -n "$MANAGED_INSTRUCTIONS_FILE" ]; then - managed_arg_display=" --managed-instructions-file $MANAGED_INSTRUCTIONS_FILE" + owned_arg_display=" --managed-instructions-file $MANAGED_INSTRUCTIONS_FILE" fi local claude_auth_arg_display="" if [ -n "$CLAUDE_CODE_AUTH_PLUGIN" ]; then claude_auth_arg_display=" --claude-code-auth-plugin $CLAUDE_CODE_AUTH_PLUGIN" fi - echo -e "${BLUE}[dry-run]${NC} Would run: python3 $HELPER --file $OPENCODE_JSON_FILE --runtime $RUNTIME_ARG --chat-bridge $BRIDGE_ARG --posture ${POSTURE:-engineering} --kimaki-plugins-dir $PLUGINS_DIR$claude_auth_arg_display$managed_arg_display $MODE_FLAG" + echo -e "${BLUE}[dry-run]${NC} Would run: python3 $HELPER --file $OPENCODE_JSON_FILE --runtime $RUNTIME_ARG --chat-bridge $BRIDGE_ARG --source-mode ${SOURCE_MODE:-workspace} --kimaki-plugins-dir $PLUGINS_DIR$claude_auth_arg_display$owned_arg_display $MODE_FLAG" local dry_out local managed_args=() if [ -n "$MANAGED_INSTRUCTIONS_FILE" ]; then @@ -770,8 +773,8 @@ for item in data: --file "$OPENCODE_JSON_FILE" \ --runtime "$RUNTIME_ARG" \ --chat-bridge "$BRIDGE_ARG" \ - --posture "${POSTURE:-engineering}" \ - "${_managed_source_args[@]}" \ + --source-mode "${SOURCE_MODE:-workspace}" \ + "${_owned_source_args[@]}" \ --kimaki-plugins-dir "$PLUGINS_DIR" \ "${claude_code_auth_args[@]}" \ "${managed_args[@]}" 2>&1 || true) @@ -789,8 +792,8 @@ for item in data: --file "$OPENCODE_JSON_FILE" \ --runtime "$RUNTIME_ARG" \ --chat-bridge "$BRIDGE_ARG" \ - --posture "${POSTURE:-engineering}" \ - "${_managed_source_args[@]}" \ + --source-mode "${SOURCE_MODE:-workspace}" \ + "${_owned_source_args[@]}" \ --kimaki-plugins-dir "$PLUGINS_DIR" \ "${claude_code_auth_args[@]}" \ "${managed_args[@]}" \