diff --git a/deploy/sync-gcp-leoclean-runtime.sh b/deploy/sync-gcp-leoclean-runtime.sh new file mode 100755 index 0000000..3661155 --- /dev/null +++ b/deploy/sync-gcp-leoclean-runtime.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# Deploy the source-controlled Cloud SQL bridge and non-secret service settings +# to the GCP parallel leoclean runtime. This does not create database users, +# read secret values, send Telegram messages, or promote Cloud SQL. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROJECT="${PROJECT:-teleo-501523}" +ZONE="${ZONE:-europe-west6-a}" +INSTANCE="${INSTANCE:-teleo-prod-1}" +SERVICE="${SERVICE:-leoclean-gcp-prod-parallel.service}" +MERGED_REF="${MERGED_REF:-origin/main}" +REMOTE_PROFILE_BIN="${REMOTE_PROFILE_BIN:-/home/teleo/.hermes/profiles/leoclean/bin}" +REMOTE_DROPIN_DIR="${REMOTE_DROPIN_DIR:-/etc/systemd/system/$SERVICE.d}" +REMOTE_CLOUDSDK_CONFIG="${REMOTE_CLOUDSDK_CONFIG:-/run/leoclean-gcp-prod-parallel}" +EXPECTED_VM_SERVICE_ACCOUNT="${EXPECTED_VM_SERVICE_ACCOUNT:-sa-teleo-prod-vm@teleo-501523.iam.gserviceaccount.com}" + +WRAPPER_REL="hermes-agent/leoclean-bin/teleo-kb" +TOOL_REL="hermes-agent/leoclean-bin/cloudsql_memory_tool.py" +DROPIN_REL="systemd/leoclean-gcp-prod-parallel-cloudsql.conf" +DROPIN_NAME="10-cloudsql-memory.conf" + +DRY_RUN=false +RESTART=false +VERIFY_ONLY=false + +usage() { + cat <<'USAGE' +Usage: deploy/sync-gcp-leoclean-runtime.sh [--dry-run] [--restart] [--verify-only] + +The database role and scoped Secret Manager secret must exist before --restart. +This script deploys only the bridge files and non-secret systemd drop-in. +USAGE +} + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --restart) RESTART=true ;; + --verify-only) VERIFY_ONLY=true ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown arg: $arg" >&2 + usage >&2 + exit 64 + ;; + esac +done + +for path in "$WRAPPER_REL" "$TOOL_REL" "$DROPIN_REL"; do + if [ ! -f "$REPO_ROOT/$path" ]; then + echo "Required source file is missing: $path" >&2 + exit 66 + fi +done + +if ! grep -Fxq 'UnsetEnvironment=PGPASSWORD' "$REPO_ROOT/$DROPIN_REL"; then + echo "Runtime drop-in must explicitly unset inherited PGPASSWORD." >&2 + exit 65 +fi +if ! grep -Fxq "Environment=CLOUDSDK_CONFIG=$REMOTE_CLOUDSDK_CONFIG" "$REPO_ROOT/$DROPIN_REL"; then + echo "Runtime drop-in CLOUDSDK_CONFIG does not match $REMOTE_CLOUDSDK_CONFIG." >&2 + exit 65 +fi + +SOURCE_REVISION="$(git -C "$REPO_ROOT" rev-parse HEAD)" +if ! git -C "$REPO_ROOT" diff --quiet HEAD -- "$WRAPPER_REL" "$TOOL_REL" "$DROPIN_REL"; then + echo "Refusing to deploy runtime files that do not match HEAD." >&2 + exit 65 +fi +if [ -n "$(git -C "$REPO_ROOT" ls-files --others --exclude-standard -- "$WRAPPER_REL" "$TOOL_REL" "$DROPIN_REL")" ]; then + echo "Refusing to deploy untracked runtime files." >&2 + exit 65 +fi +if ! git -C "$REPO_ROOT" rev-parse --verify "$MERGED_REF^{commit}" >/dev/null 2>&1; then + echo "Merged reference is unavailable: $MERGED_REF" >&2 + exit 69 +fi +if ! git -C "$REPO_ROOT" merge-base --is-ancestor "$SOURCE_REVISION" "$MERGED_REF"; then + echo "Refusing to deploy unmerged revision $SOURCE_REVISION (not contained in $MERGED_REF)." >&2 + exit 65 +fi +WRAPPER_SHA="$(shasum -a 256 "$REPO_ROOT/$WRAPPER_REL" | awk '{print $1}')" +TOOL_SHA="$(shasum -a 256 "$REPO_ROOT/$TOOL_REL" | awk '{print $1}')" +DROPIN_SHA="$(shasum -a 256 "$REPO_ROOT/$DROPIN_REL" | awk '{print $1}')" + +GCP_SSH=( + gcloud compute ssh "$INSTANCE" + --project="$PROJECT" + --zone="$ZONE" + --tunnel-through-iap +) + +remote_helpers=$(cat <<'REMOTE' +assert_service_running() { + local active_state sub_state main_pid unit_user process_user + active_state="$(systemctl show "$SERVICE" --property=ActiveState --value)" + sub_state="$(systemctl show "$SERVICE" --property=SubState --value)" + if [ "$active_state" != "active" ] || [ "$sub_state" != "running" ]; then + echo "Service is not active/running: ActiveState=$active_state SubState=$sub_state" >&2 + return 1 + fi + + main_pid="$(systemctl show "$SERVICE" --property=MainPID --value)" + unit_user="$(systemctl show "$SERVICE" --property=User --value)" + case "$main_pid" in + ''|0|*[!0-9]*) + echo "Service has no live MainPID: $main_pid" >&2 + return 1 + ;; + esac + if [ "$unit_user" != "teleo" ]; then + echo "Service unit user is not teleo: $unit_user" >&2 + return 1 + fi + process_user="$(ps -o user= -p "$main_pid" | awk '{print $1}')" + if [ "$process_user" != "teleo" ]; then + echo "Service process user is not teleo: $process_user" >&2 + return 1 + fi +} + +assert_runtime_environment() { + local environment runtime_dir_state + environment="$(systemctl show "$SERVICE" --property=Environment --value)" + case " $environment " in + *" PGPASSWORD="*) + echo "Service environment still contains PGPASSWORD." >&2 + return 1 + ;; + esac + case " $environment " in + *" CLOUDSDK_CONFIG=$REMOTE_CLOUDSDK_CONFIG "*) ;; + *) + echo "Service does not use the isolated CLOUDSDK_CONFIG." >&2 + return 1 + ;; + esac + runtime_dir_state="$(stat -c '%U:%G:%a' "$REMOTE_CLOUDSDK_CONFIG")" + if [ "$runtime_dir_state" != "teleo:teleo:700" ]; then + echo "Isolated CLOUDSDK_CONFIG has unexpected ownership or mode: $runtime_dir_state" >&2 + return 1 + fi +} + +assert_attached_service_account() { + local sdk_config="$1" metadata_email + sudo install -d -o teleo -g teleo -m 0700 "$sdk_config" + metadata_email="$( + sudo -n -u teleo env -u PGPASSWORD \ + HOME=/home/teleo \ + CLOUDSDK_CONFIG="$sdk_config" \ + curl --fail --silent --show-error \ + --header 'Metadata-Flavor: Google' \ + 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email' + )" + if [ "$metadata_email" != "$EXPECTED_VM_SERVICE_ACCOUNT" ]; then + echo "Attached VM service account mismatch: $metadata_email" >&2 + return 1 + fi + + # An empty, phase-local Cloud SDK config forces this token request to use + # the VM's attached identity instead of an operator's cached credentials. + sudo -n -u teleo env -u PGPASSWORD \ + HOME=/home/teleo \ + CLOUDSDK_CONFIG="$sdk_config" \ + CLOUDSDK_CORE_PROJECT="$PROJECT" \ + gcloud --quiet auth print-access-token >/dev/null + echo "ATTACHED_VM_SERVICE_ACCOUNT=$metadata_email" +} + +run_scoped_status() { + local phase="$1" tool_path="$2" sdk_config="$3" + assert_attached_service_account "$sdk_config" + echo "SCOPED_STATUS phase=$phase user=teleo database=teleo_canonical" + sudo -n -u teleo env -u PGPASSWORD \ + HOME=/home/teleo \ + CLOUDSDK_CONFIG="$sdk_config" \ + CLOUDSDK_CORE_PROJECT="$PROJECT" \ + TELEO_GCP_PROJECT=teleo-501523 \ + TELEO_CLOUDSQL_HOST=10.61.0.3 \ + TELEO_CLOUDSQL_PORT=5432 \ + TELEO_CLOUDSQL_DB=teleo_canonical \ + TELEO_CANONICAL_CLOUDSQL_DB=teleo_canonical \ + TELEO_CLOUDSQL_USER=leoclean_kb_runtime \ + TELEO_CLOUDSQL_PASSWORD_SECRET=gcp-teleo-pgvector-standby-leoclean-kb-runtime-password \ + TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK=0 \ + python3 "$tool_path" status --format json --redacted +} + +assert_deployed_files() { + local wrapper tool dropin + wrapper="$REMOTE_PROFILE_BIN/teleo-kb" + tool="$REMOTE_PROFILE_BIN/cloudsql_memory_tool.py" + dropin="$REMOTE_DROPIN_DIR/$DROPIN_NAME" + test "$(sha256sum "$wrapper" | awk '{print $1}')" = "$WRAPPER_SHA" + test "$(sha256sum "$tool" | awk '{print $1}')" = "$TOOL_SHA" + test "$(sudo sha256sum "$dropin" | awk '{print $1}')" = "$DROPIN_SHA" + sudo grep -Fx 'UnsetEnvironment=PGPASSWORD' "$dropin" + sudo grep -E '^Environment=(CLOUDSDK_CONFIG|TELEO_(KB_MODE|GCP_PROJECT|CLOUDSQL_|CANONICAL_CLOUDSQL_DB))' "$dropin" +} +REMOTE +) + +printf -v verify_context \ + 'PROJECT=%q\nSERVICE=%q\nEXPECTED_VM_SERVICE_ACCOUNT=%q\nREMOTE_PROFILE_BIN=%q\nREMOTE_DROPIN_DIR=%q\nREMOTE_CLOUDSDK_CONFIG=%q\nDROPIN_NAME=%q\nWRAPPER_SHA=%q\nTOOL_SHA=%q\nDROPIN_SHA=%q\n' \ + "$PROJECT" "$SERVICE" "$EXPECTED_VM_SERVICE_ACCOUNT" "$REMOTE_PROFILE_BIN" \ + "$REMOTE_DROPIN_DIR" "$REMOTE_CLOUDSDK_CONFIG" "$DROPIN_NAME" \ + "$WRAPPER_SHA" "$TOOL_SHA" "$DROPIN_SHA" + +verify_body=$(cat <<'REMOTE' +set -euo pipefail +verification_tmp="$(mktemp -d /tmp/teleo-gcp-runtime-verify.XXXXXX)" +cleanup_verification() { sudo rm -rf -- "$verification_tmp"; } +trap cleanup_verification EXIT +assert_deployed_files +assert_service_running +assert_runtime_environment +run_scoped_status verify "$REMOTE_PROFILE_BIN/cloudsql_memory_tool.py" "$verification_tmp/gcloud" +systemctl show "$SERVICE" -p ActiveState -p SubState -p NRestarts -p MainPID -p User --no-pager +REMOTE +) +verify_command="${verify_context}${remote_helpers}"$'\n'"${verify_body}" + +if $DRY_RUN; then + echo "DRY RUN: project=$PROJECT zone=$ZONE instance=$INSTANCE service=$SERVICE restart=$RESTART" + echo "DRY RUN: revision=$SOURCE_REVISION wrapper_sha256=$WRAPPER_SHA tool_sha256=$TOOL_SHA dropin_sha256=$DROPIN_SHA" + exit 0 +fi + +if $VERIFY_ONLY; then + "${GCP_SSH[@]}" --command="$verify_command" + exit 0 +fi + +if ! $RESTART; then + echo "Refusing to install runtime files without --restart; use --dry-run or --verify-only for read-only work." >&2 + exit 64 +fi + +REMOTE_TMP="/tmp/teleo-gcp-leoclean-runtime-$(date -u +%Y%m%dT%H%M%SZ)-$$" +printf -v sync_context \ + 'PROJECT=%q\nSERVICE=%q\nEXPECTED_VM_SERVICE_ACCOUNT=%q\nREMOTE_PROFILE_BIN=%q\nREMOTE_DROPIN_DIR=%q\nREMOTE_CLOUDSDK_CONFIG=%q\nDROPIN_NAME=%q\nWRAPPER_SHA=%q\nTOOL_SHA=%q\nDROPIN_SHA=%q\nREMOTE_TMP=%q\nWRAPPER_REL=%q\nTOOL_REL=%q\nDROPIN_REL=%q\nSOURCE_REVISION=%q\n' \ + "$PROJECT" "$SERVICE" "$EXPECTED_VM_SERVICE_ACCOUNT" "$REMOTE_PROFILE_BIN" \ + "$REMOTE_DROPIN_DIR" "$REMOTE_CLOUDSDK_CONFIG" "$DROPIN_NAME" \ + "$WRAPPER_SHA" "$TOOL_SHA" "$DROPIN_SHA" "$REMOTE_TMP" \ + "$WRAPPER_REL" "$TOOL_REL" "$DROPIN_REL" "$SOURCE_REVISION" + +sync_body=$(cat <<'REMOTE' +set -euo pipefail +remote_tmp="$REMOTE_TMP" +profile_bin="$REMOTE_PROFILE_BIN" +dropin_dir="$REMOTE_DROPIN_DIR" +backup_dir="$remote_tmp/backup" +dropin_stage="$dropin_dir/.${DROPIN_NAME}.livingip-new.$$" +mutation_started=false + +backup_path() { + local target="$1" name="$2" + if sudo test -e "$target"; then + sudo cp -a -- "$target" "$backup_dir/$name" + touch "$backup_dir/$name.present" + fi +} + +restore_path() { + local target="$1" name="$2" restore_rc=0 restore_tmp + if [ -f "$backup_dir/$name.present" ]; then + restore_tmp="${target}.livingip-rollback.$$" + sudo rm -f -- "$restore_tmp" || restore_rc=1 + if sudo cp -a -- "$backup_dir/$name" "$restore_tmp"; then + sudo mv -f -- "$restore_tmp" "$target" || restore_rc=1 + else + restore_rc=1 + fi + sudo rm -f -- "$restore_tmp" || restore_rc=1 + else + sudo rm -f -- "$target" || restore_rc=1 + fi + return "$restore_rc" +} + +rollback_runtime() { + local rollback_rc=0 + echo "Deployment failed; restoring the complete previous runtime set." >&2 + sudo systemctl stop "$SERVICE" || rollback_rc=1 + restore_path "$profile_bin/teleo-kb" wrapper || rollback_rc=1 + restore_path "$profile_bin/cloudsql_memory_tool.py" tool || rollback_rc=1 + restore_path "$dropin_dir/$DROPIN_NAME" dropin || rollback_rc=1 + restore_path "$profile_bin/.livingip-runtime-revision" revision || rollback_rc=1 + sudo systemctl daemon-reload || rollback_rc=1 + if [ "$rollback_rc" -eq 0 ]; then + sudo systemctl start "$SERVICE" || rollback_rc=1 + assert_service_running || rollback_rc=1 + fi + return "$rollback_rc" +} + +on_exit() { + local rc="$1" rollback_rc=0 + trap - EXIT INT TERM + if [ "$rc" -ne 0 ] && [ "$mutation_started" = true ]; then + set +e + rollback_runtime + rollback_rc=$? + set -e + if [ "$rollback_rc" -ne 0 ]; then + echo "CRITICAL: previous runtime restoration did not return the service to active/running." >&2 + rc=1 + fi + fi + sudo rm -f -- "$dropin_stage" || true + sudo rm -rf -- "$remote_tmp" || true + exit "$rc" +} + +trap 'on_exit $?' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +sudo rm -rf -- "$remote_tmp" +mkdir -p "$backup_dir" +tar -C "$remote_tmp" -xf - + +assert_service_running +run_scoped_status pre "$remote_tmp/$TOOL_REL" "$remote_tmp/gcloud-pre" + +backup_path "$profile_bin/teleo-kb" wrapper +backup_path "$profile_bin/cloudsql_memory_tool.py" tool +backup_path "$dropin_dir/$DROPIN_NAME" dropin +backup_path "$profile_bin/.livingip-runtime-revision" revision + +mutation_started=true +sudo systemctl stop "$SERVICE" +sudo install -d -o teleo -g teleo -m 0755 "$profile_bin" +sudo install -o teleo -g teleo -m 0755 "$remote_tmp/$WRAPPER_REL" "$profile_bin/teleo-kb" +sudo install -o teleo -g teleo -m 0755 "$remote_tmp/$TOOL_REL" "$profile_bin/cloudsql_memory_tool.py" +sudo install -d -o root -g root -m 0755 "$dropin_dir" +sudo install -o root -g root -m 0644 "$remote_tmp/$DROPIN_REL" "$dropin_stage" +sudo mv -f -- "$dropin_stage" "$dropin_dir/$DROPIN_NAME" +printf '%s\n' "$SOURCE_REVISION" | sudo tee "$profile_bin/.livingip-runtime-revision" >/dev/null +sudo chown teleo:teleo "$profile_bin/.livingip-runtime-revision" +sudo chmod 0644 "$profile_bin/.livingip-runtime-revision" +sudo systemctl daemon-reload +sudo systemctl start "$SERVICE" + +assert_deployed_files +assert_service_running +assert_runtime_environment +run_scoped_status post "$profile_bin/cloudsql_memory_tool.py" "$remote_tmp/gcloud-post" +systemctl show "$SERVICE" -p ActiveState -p SubState -p NRestarts -p MainPID -p User --no-pager +mutation_started=false +REMOTE +) +sync_command="${sync_context}${remote_helpers}"$'\n'"${sync_body}" + +COPYFILE_DISABLE=1 tar --format=ustar -C "$REPO_ROOT" -cf - \ + "$WRAPPER_REL" "$TOOL_REL" "$DROPIN_REL" | "${GCP_SSH[@]}" --command="$sync_command" diff --git a/docs/gcp-leoclean-runtime-reconciliation.md b/docs/gcp-leoclean-runtime-reconciliation.md new file mode 100644 index 0000000..754b15b --- /dev/null +++ b/docs/gcp-leoclean-runtime-reconciliation.md @@ -0,0 +1,157 @@ +# GCP Leo Runtime Reconciliation + +## Scope + +This runbook reconciles PR `#145` with the non-production GCP parallel Leo +service and removes its dependency on the PostgreSQL administrator credential. +It does not promote GCP, change the Telegram destination, copy newer VPS data, +or make `teleo_canonical` production-authoritative. + +Target surfaces: + +- project: `teleo-501523`; +- VM: `teleo-prod-1` in `europe-west6-a`; +- service: `leoclean-gcp-prod-parallel.service`; +- Cloud SQL database: `teleo_canonical` on private address `10.61.0.3`; +- runtime database role: `leoclean_kb_runtime`; +- staging-function owner: `leoclean_kb_stage_owner` (`NOLOGIN`); +- runtime secret: `gcp-teleo-pgvector-standby-leoclean-kb-runtime-password`. + +Observed before reconciliation on 2026-07-14: the service was +`active/running`, but `/usr/local/bin/teleo-kb` matched unmerged PR `#145`, the +effective systemd environment selected database user `postgres` and the +administrator password secret, and a live `teleo-kb status` call exceeded a +20-second bound. Secret Manager access and private PostgreSQL reachability both +passed independently, isolating the timeout to the old helper path rather than +IAM or networking. + +## Required End State + +1. The live wrapper and Cloud SQL helper hashes match a merged repository + commit. +2. `TELEO_KB_MODE=cloudsql`; missing tools or credentials fail closed. +3. Canonical zero-hit searches do not consult `teleo_restore` unless an + operator explicitly opts in. +4. Leo can read the named canonical tables and stage only a + `pending_review` proposal through `kb_stage.stage_leoclean_proposal(...)`. +5. Leo cannot directly insert, update, or delete `public.*` or + `kb_stage.kb_proposals`, forge the proposer identity, `SET ROLE` into a + broader principal, or execute reviewer/apply functions. +6. The VM runtime identity can access only the scoped password secret needed by + this path, not the PostgreSQL administrator password secret. +7. Deployment proves the attached VM service account from the metadata server, + uses an empty phase-local Cloud SDK configuration, and rolls the complete + prior runtime set back if installation or post-restart verification fails. + +## Safe Order Of Operations + +### 1. Read-only access and IAM audit + +Confirm the operator can read the project, use IAP/OS Login for the VM, inspect +Cloud SQL metadata, and inspect Secret Manager IAM. Identify the VM service +account and determine whether its secret access is project-wide or +secret-specific. Do not remove a project-wide binding until every legitimately +required runtime secret has an equivalent secret-level binding. + +### 2. Merge reviewed repository code + +Run CI on the completed PR `#145` repair before deployment. The deployment +source must be the resulting merged commit, not a working tree or unmerged +branch. + +### 3. Create the scoped secret and grant only the VM runtime identity + +Create the scoped secret without printing its value. Add one randomly generated +version, then grant `roles/secretmanager.secretAccessor` on that secret to the +VM runtime service account. Do not put `PGPASSWORD` in systemd, a repository, +an artifact, or a command transcript. + +### 4. Provision the scoped PostgreSQL role once + +Run `ops/gcp_leoclean_runtime_role.sql` as the Cloud SQL administrator from the +private VM path. Supply the new runtime password through +`TELEO_LEOCLEAN_DB_PASSWORD`; the SQL file does not contain it. The migration: + +- creates or rotates `leoclean_kb_runtime`; +- creates a dedicated `NOLOGIN` function owner with no role memberships; +- removes stale table, column, sequence, function, and role-membership grants; +- grants exact canonical reads; +- creates a locked security-definer staging function that hard-codes both + `pending_review` and canonical proposer `leo`; +- grants no direct table writes; +- removes all access to the legacy `teleo_restore` schema; +- aborts if either scoped role owns or can reach anything outside the explicit + allowlist. + +PostgreSQL grants `TEMP` to `PUBLIC` by default. The migration removes any +direct scoped `TEMP` grant and reports the remaining effective privilege, but +does not revoke `TEMP` from `PUBLIC`: PostgreSQL has no per-role deny, so that +would be a database-wide behavior change requiring a separate inventory of +`kb_apply`, reviewer, and operator use. The staging function remains protected +by a `pg_catalog, pg_temp` search path, schema-qualified relations, a fixed +`session_user`, and a tested temporary-object shadowing denial. + +The administrator password is used only for this bounded bootstrap. Retain no +password output. + +### 5. Preflight and deploy the merged runtime + +From the exact merged checkout, run: + +```bash +deploy/sync-gcp-leoclean-runtime.sh --dry-run +deploy/sync-gcp-leoclean-runtime.sh --restart +``` + +The deploy script runs a scoped, redacted database status preflight before it +installs files or restarts the service. It refuses dirty or unmerged runtime +files and refuses a live install without `--restart`. After preflight it stops +the parallel service, atomically replaces the existing Cloud SQL systemd +drop-in, installs the reviewed wrapper and helper, records the Git revision, +starts the service, and verifies all three file hashes. A failure after mutation +restores the prior wrapper, helper, drop-in, and revision as one set before the +old service is restarted. + +### 6. Verify positive and negative behavior + +Retain a redacted receipt containing: + +- merged Git commit and deployed file hashes; +- service `ActiveState`, `SubState`, `MainPID`, and `NRestarts`; +- metadata-server identity and an access token obtained with an empty Cloud SDK + configuration; +- `current_database()` and `current_user` showing + `teleo_canonical|leoclean_kb_runtime`; +- a real canonical status/search receipt; +- a transaction-rolled-back call to `stage_leoclean_proposal(...)` that returns + `pending_review`; +- denied direct insert into `kb_stage.kb_proposals`; +- denied canonical `public.*` write; +- denied reviewer/apply security-definer function; +- denied forged proposer identity and denied `SET ROLE` escalation; +- zero Telegram messages and zero committed canary rows. + +### 7. Remove administrator-secret access + +Only after the scoped service passes post-restart verification, remove the VM +runtime identity's access path to +`gcp-teleo-pgvector-standby-postgres-password`. Verify from the VM runtime +identity that the scoped secret is readable and the administrator secret is +denied. Never delete the administrator secret merely to enforce runtime least +privilege; backup/restore operators may still require it under a separate +identity. + +## Stop Conditions + +Stop without cutover if any of these are true: + +- the deployed revision is not merged; +- the scoped status preflight fails; +- the role has direct proposal-table insert or canonical write permission; +- any approval/apply function is executable by the runtime role; +- removing broad Secret Manager access would break another required secret; +- GCP canonical rows are still stale relative to the chosen authority. + +The last condition does not invalidate this runtime security repair. It means +GCP remains staging and data reconciliation stays a separate, explicitly +authorized slice. diff --git a/docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json b/docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json index 651db21..9ad235f 100644 --- a/docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json +++ b/docs/reports/leo-working-state-20260709/gcp-db-first-live-deploy-restart-current.json @@ -6,6 +6,7 @@ "database_first_merge_commit": "08371bf2a0461536c112e1235a7229b52ed270cc", "database_first_pull_request": 144, "fail_closed_wrapper_commit": "8451c51", + "fail_closed_wrapper_merge_state_at_capture": "unmerged PR #145; manually installed on the GCP VM", "repository": "living-ip/teleo-infrastructure" }, "deployment": { @@ -55,7 +56,7 @@ "integration_regression": { "detected_live": true, "symptom": "The old auto-mode wrapper used a full receipted status call as a 30-second health probe; the stronger read exceeded that bound and the wrapper fell through to a different local tool.", - "repair": "Supported GCP knowledge commands now route directly to Cloud SQL and fail closed on errors instead of changing databases.", + "repair": "The manually installed PR #145 wrapper routes supported GCP knowledge commands directly to Cloud SQL and propagates failures after selection. Missing prerequisites could still fall through before selection at capture time.", "regression_tests": 2, "full_repository_tests": { "passed": 1240, @@ -113,8 +114,10 @@ "post_restart_model_turn_run": false }, "claim_ceiling": { - "proven": "Merged database-first helper and skill deployment, fail-closed Cloud SQL routing, controlled GCP service restart, and post-restart live status/search receipts.", + "proven": "Merged database-first helper and skill deployment, manually installed unmerged wrapper routing repair, controlled GCP service restart, and post-restart live status/search receipts.", "not_proven": [ + "repository/runtime parity for the wrapper", + "least-privilege Cloud SQL runtime credential", "post-restart model reasoning turn", "Telegram-visible delivery", "canonical proposal apply", diff --git a/docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md b/docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md index b33ff7b..d906df5 100644 --- a/docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md +++ b/docs/reports/leo-working-state-20260709/gcp-db-first-working-leo-20260714.md @@ -94,10 +94,11 @@ remaining target databases. The disabled rollback database still has zero connections. The live GCP Leo service remained PID `148735`, active/running, with `NRestarts=0` throughout restore, model replay, and cleanup. -### 5. The merged GCP path survived deployment and restart +### 5. The merged database-first path and an unmerged wrapper repair survived restart PR `#144` merged the database-first helper and skill. Their exact reviewed -hashes were installed on `teleo-prod-1`, then +hashes were installed on `teleo-prod-1`. A separate wrapper repair from the +then-unmerged PR `#145` was also installed manually, then `leoclean-gcp-prod-parallel.service` was intentionally restarted. It returned active/running with a new PID (`304036`) and the deployed hashes remained in place. @@ -105,9 +106,12 @@ place. The first post-restart search caught one real integration regression: the old wrapper used a full `status` read as a 30-second health probe. Stronger receipts made that probe exceed its bound, after which `auto` mode could silently select -a different local tool. The wrapper now routes every supported GCP knowledge -command directly to Cloud SQL and fails closed on errors. Two regression tests -cover direct routing and no fallback. +a different local tool. The manually deployed wrapper routes every supported +GCP knowledge command directly to Cloud SQL and propagates errors after backend +selection. At the time of this receipt, missing host prerequisites could still +cause an earlier fallback, the runtime still used the administrator database +credential, and the live wrapper did not match merged `main`. PR `#145` +therefore remained a reconciliation task rather than a production-ready result. A corrected live search then found the expected sandbagging claim plus both expected source rows and hashes from persistent `teleo_canonical`. A subsequent @@ -140,7 +144,7 @@ rows as if they were source-code branches. | `gcp-db-first-parity-current.json` | `0173ee6707016e8412e6dd4326d61f71b6ef862bbc5b819079d62680676729f2` | Schema, row, role, extension, and performance parity passed | | `gcp-db-first-blind-claim-current.json` | `8a7cc3c1814eb385e858f12ef7579cd4524f37886c03fc6d9c61624b6aff2a52` | Real GCP Hermes reasoning passed with source-bound read receipts | | `gcp-db-first-cleanup-current.json` | `cac7e34f45653fabb696d911b6ccc921d3f291bf8cbe05a429d757e717dcafb4` | Clone absent, rollback disabled, service unchanged | -| `gcp-db-first-live-deploy-restart-current.json` | `d70fdbac859b8c98d557f4df900139a93e6009e12f7b80c9adbd69a5655b9df7` | Merged deploy, fail-closed routing, restart, and live status/search readback passed | +| `gcp-db-first-live-deploy-restart-current.json` | `78e9dde38d641bfa3d58b2b7d8605b37f2861605707eddc0c94ad5c9a70d080d` | Merged PR `#144` files plus an unmerged PR `#145` wrapper were installed; restart and live status/search readback passed | ## What This Unlocks @@ -166,6 +170,9 @@ rows as if they were source-code branches. 6. A model reasoning turn after the controlled GCP restart. The post-restart proof in this report is the real service plus live `teleo-kb` status/search path, not a new paid model response. +7. Repository/runtime parity for the wrapper or a least-privilege Cloud SQL + credential. The captured service still used an unmerged wrapper and the + administrator database secret. The next product-level proof is one natural Telegram challenge that reaches the same database-grounded reasoning, followed by one explicitly approved diff --git a/hermes-agent/leoclean-bin/cloudsql_memory_tool.py b/hermes-agent/leoclean-bin/cloudsql_memory_tool.py index a7fb4fe..dd2edbe 100755 --- a/hermes-agent/leoclean-bin/cloudsql_memory_tool.py +++ b/hermes-agent/leoclean-bin/cloudsql_memory_tool.py @@ -74,6 +74,9 @@ STOPWORDS = { DEFAULT_CLAIM_BASE_URL = "https://leo.livingip.xyz" RETRIEVAL_RECEIPT_SCHEMA = "livingip.teleoKbRetrievalReceipt.v1" +RUNTIME_DB_USER = "leoclean_kb_runtime" +RUNTIME_PASSWORD_SECRET = "gcp-teleo-pgvector-standby-leoclean-kb-runtime-password" +CLONE_READ_ONLY_PGOPTIONS = "-c default_transaction_read_only=on" RECEIPTED_READ_COMMANDS = frozenset( { "search", @@ -94,14 +97,20 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default=os.environ.get("TELEO_CLOUDSQL_HOST", "10.61.0.3")) parser.add_argument("--port", default=os.environ.get("TELEO_CLOUDSQL_PORT", "5432")) - parser.add_argument("--db", default=os.environ.get("TELEO_CLOUDSQL_DB", "teleo_kb")) + parser.add_argument("--db", default=os.environ.get("TELEO_CLOUDSQL_DB", "teleo_canonical")) parser.add_argument("--canonical-db", default=os.environ.get("TELEO_CANONICAL_CLOUDSQL_DB", "teleo_canonical")) - parser.add_argument("--user", default=os.environ.get("TELEO_CLOUDSQL_USER", "postgres")) + parser.add_argument("--user", default=os.environ.get("TELEO_CLOUDSQL_USER")) parser.add_argument( "--password-secret", - default=os.environ.get("TELEO_CLOUDSQL_PASSWORD_SECRET", "gcp-teleo-pgvector-standby-postgres-password"), + default=os.environ.get("TELEO_CLOUDSQL_PASSWORD_SECRET"), ) parser.add_argument("--project", default=os.environ.get("TELEO_GCP_PROJECT", "teleo-501523")) + parser.add_argument( + "--credential-mode", + choices=["runtime", "clone-readonly"], + default=os.environ.get("TELEO_CLOUDSQL_CREDENTIAL_MODE", "runtime"), + help="Runtime mode requires the exact scoped Leo identity. Clone mode is limited to read-only teleo_clone_* tests.", + ) parser.add_argument("--format", choices=["markdown", "json"], default="markdown") parser.add_argument( "--include-excerpts", @@ -185,7 +194,7 @@ def parse_args() -> argparse.Namespace: propose.add_argument("--proposed", required=True, help="Proposed replacement or correction.") propose.add_argument("--evidence", action="append", default=[], help="Evidence/source pointer. Can be repeated.") propose.add_argument("--implication", action="append", default=[], help="Downstream implication. Can be repeated.") - propose.add_argument("--proposed-by", default="leo") + propose.add_argument("--proposed-by", choices=["leo"], default="leo") propose.add_argument("--originator", default="", help="Human or agent who originated the correction.") propose.add_argument("--channel", default="telegram") propose.add_argument("--source-ref", required=True) @@ -196,7 +205,7 @@ def parse_args() -> argparse.Namespace: propose_edge.add_argument("from_claim") propose_edge.add_argument("edge_type") propose_edge.add_argument("to_claim") - propose_edge.add_argument("--proposed-by", default="leo") + propose_edge.add_argument("--proposed-by", choices=["leo"], default="leo") propose_edge.add_argument("--channel", default="telegram") propose_edge.add_argument("--source-ref", required=True) propose_edge.add_argument("--rationale", required=True) @@ -228,6 +237,34 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def validate_runtime_connection(args: argparse.Namespace) -> None: + user = str(args.user or "") + password_secret = str(args.password_secret or "") + mode = str(getattr(args, "credential_mode", "runtime")) + if mode == "runtime": + if user != RUNTIME_DB_USER: + raise SystemExit(f"Leo runtime requires database user {RUNTIME_DB_USER}.") + if password_secret != RUNTIME_PASSWORD_SECRET: + raise SystemExit(f"Leo runtime requires scoped password secret {RUNTIME_PASSWORD_SECRET}.") + if "PGPASSWORD" in os.environ: + raise SystemExit("Refusing inherited PGPASSWORD in Leo runtime mode.") + return + + if mode != "clone-readonly": + raise SystemExit(f"Unsupported Cloud SQL credential mode: {mode}.") + canonical_db = str(args.canonical_db or "") + audit_db = str(args.db or "") + pgoptions = os.environ.get("PGOPTIONS", "") + if not re.fullmatch(r"teleo_clone_[a-z0-9_]+", canonical_db) or audit_db != canonical_db: + raise SystemExit("Clone credential mode is limited to one teleo_clone_* database.") + if args.command not in RECEIPTED_READ_COMMANDS: + raise SystemExit("Clone credential mode permits read commands only.") + if pgoptions != CLONE_READ_ONLY_PGOPTIONS: + raise SystemExit("Clone credential mode requires server-enforced default_transaction_read_only=on.") + if not user or (not password_secret and not os.environ.get("PGPASSWORD")): + raise SystemExit("Clone credential mode requires an explicit user and credential source.") + + def terms_for(query: str) -> list[str]: terms: list[str] = [] for token in re.findall(r"[A-Za-z0-9][A-Za-z0-9.+#/-]*", query.lower()): @@ -269,8 +306,11 @@ def sql_array(values: list[str], cast: str = "text") -> str: def password(args: argparse.Namespace) -> str: - if os.environ.get("PGPASSWORD"): + validate_runtime_connection(args) + if args.credential_mode == "clone-readonly" and os.environ.get("PGPASSWORD"): return os.environ["PGPASSWORD"] + if not args.password_secret: + raise SystemExit("Scoped Cloud SQL password secret is not configured.") result = subprocess.run( [ "gcloud", @@ -287,15 +327,32 @@ def password(args: argparse.Namespace) -> str: ) if result.returncode != 0: raise SystemExit(f"Secret access failed: {result.stderr.strip()}") - return result.stdout.strip() + credential = result.stdout.strip() + if not credential: + raise SystemExit("Scoped Cloud SQL password secret resolved to an empty value.") + return credential def run_psql(args: argparse.Namespace, sql: str, db: str | None = None) -> str: env = os.environ.copy() - env["PGPASSWORD"] = password(args) - conn = f"host={args.host} port={args.port} dbname={db or args.db} user={args.user} sslmode=require" + for key in tuple(env): + if key.startswith("PG"): + env.pop(key, None) + env.update( + { + "PGHOST": str(args.host), + "PGPORT": str(args.port), + "PGDATABASE": str(db or args.db), + "PGUSER": str(args.user), + "PGSSLMODE": "require", + "PGCONNECT_TIMEOUT": "8", + "PGPASSWORD": password(args), + } + ) + if args.credential_mode == "clone-readonly": + env["PGOPTIONS"] = CLONE_READ_ONLY_PGOPTIONS result = subprocess.run( - ["psql", conn, "-At", "-q", "-v", "ON_ERROR_STOP=1"], + ["psql", "-X", "-At", "-q", "-v", "ON_ERROR_STOP=1"], text=True, capture_output=True, input=sql, @@ -868,6 +925,11 @@ def query_rows(args: argparse.Namespace, query: str, limit: int) -> dict[str, An canonical = query_canonical_rows(args, query, limit) if canonical.get("hit_count_total", 0) or canonical.get("hits"): return canonical + if os.environ.get("TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK", "") != "1": + canonical["canonical_fallback_reason"] = ( + "no matching canonical public/persona/strategy/belief rows; audit fallback disabled" + ) + return canonical if not audit_restore_available(args): canonical["canonical_fallback_reason"] = ( "no matching canonical public/persona/strategy/belief rows; teleo_restore audit fallback unavailable" @@ -1054,57 +1116,37 @@ def propose_core_change(args: argparse.Namespace) -> dict[str, Any]: with params as ( select {sql_literal(args.proposal_type)}::text as proposal_type, - nullif({sql_literal(args.proposed_by)}, '') as proposed_by_handle, nullif({sql_literal(args.channel)}, '') as channel, nullif({sql_literal(args.source_ref)}, '') as source_ref, {sql_literal(args.rationale)} as rationale, {sql_literal(payload_json)}::jsonb as payload ), proposer as ( - select a.id, a.handle - from public.agents a, params p - where a.handle = p.proposed_by_handle - limit 1 -), inserted as ( - insert into kb_stage.kb_proposals ( - proposal_type, - status, - proposed_by_handle, - proposed_by_agent_id, - channel, - source_ref, - rationale, - payload - ) - select + select kb_stage.stage_leoclean_proposal( p.proposal_type, - 'pending_review', - p.proposed_by_handle, - proposer.id, - coalesce(p.channel, 'telegram'), + p.channel, p.source_ref, p.rationale, p.payload + ) as proposal from params p - left join proposer on true - returning * ) select jsonb_build_object( 'artifact', 'teleo_cloudsql_kb_core_change_proposal', 'backend', {sql_literal(canonical_backend(args))}, - 'id', id::text, - 'proposal_type', proposal_type, - 'status', status, - 'proposed_by_handle', proposed_by_handle, - 'proposed_by_agent_id', proposed_by_agent_id::text, - 'channel', channel, - 'source_ref', source_ref, - 'rationale', rationale, - 'payload', payload, - 'created_at', created_at::text, + 'id', proposal->>'id', + 'proposal_type', proposal->>'proposal_type', + 'status', proposal->>'status', + 'proposed_by_handle', proposal->>'proposed_by_handle', + 'proposed_by_agent_id', proposal->>'proposed_by_agent_id', + 'channel', proposal->>'channel', + 'source_ref', proposal->>'source_ref', + 'rationale', proposal->>'rationale', + 'payload', proposal->'payload', + 'created_at', proposal->>'created_at', 'canonical_apply_done', false, 'runtime_memory_write_done', false )::text -from inserted; +from proposer; """ rows = psql_json_lines(args, sql, db=args.canonical_db) if not rows: @@ -1119,7 +1161,6 @@ with params as ( {sql_literal(args.from_claim)}::uuid as from_claim, {sql_literal(args.to_claim)}::uuid as to_claim, {sql_literal(args.edge_type)}::edge_type as edge_type, - nullif({sql_literal(args.proposed_by)}, '') as proposed_by_handle, nullif({sql_literal(args.channel)}, '') as channel, nullif({sql_literal(args.source_ref)}, '') as source_ref, {sql_literal(args.rationale)} as rationale @@ -1131,11 +1172,6 @@ with params as ( select c.id, c.text from public.claims c, params p where c.id = p.to_claim -), proposer as ( - select a.id, a.handle - from public.agents a, params p - where a.handle = p.proposed_by_handle - limit 1 ), existing_edge as ( select e.id from public.claim_edges e, params p @@ -1143,23 +1179,10 @@ with params as ( and e.to_claim = p.to_claim and e.edge_type = p.edge_type limit 1 -), inserted as ( - insert into kb_stage.kb_proposals ( - proposal_type, - status, - proposed_by_handle, - proposed_by_agent_id, - channel, - source_ref, - rationale, - payload - ) - select +), staged as ( + select kb_stage.stage_leoclean_proposal( 'add_edge', - 'pending_review', - p.proposed_by_handle, - proposer.id, - coalesce(p.channel, 'telegram'), + p.channel, p.source_ref, p.rationale, jsonb_build_object( @@ -1175,29 +1198,28 @@ with params as ( 'edge_type', p.edge_type::text ) ) + ) as proposal from params p join from_row on true join to_row on true - left join proposer on true - returning * ) select jsonb_build_object( 'artifact', 'teleo_cloudsql_kb_edge_proposal', 'backend', {sql_literal(canonical_backend(args))}, - 'id', id::text, - 'proposal_type', proposal_type, - 'status', status, - 'proposed_by_handle', proposed_by_handle, - 'proposed_by_agent_id', proposed_by_agent_id::text, - 'channel', channel, - 'source_ref', source_ref, - 'rationale', rationale, - 'payload', payload, - 'created_at', created_at::text, + 'id', proposal->>'id', + 'proposal_type', proposal->>'proposal_type', + 'status', proposal->>'status', + 'proposed_by_handle', proposal->>'proposed_by_handle', + 'proposed_by_agent_id', proposal->>'proposed_by_agent_id', + 'channel', proposal->>'channel', + 'source_ref', proposal->>'source_ref', + 'rationale', proposal->>'rationale', + 'payload', proposal->'payload', + 'created_at', proposal->>'created_at', 'canonical_apply_done', false, 'runtime_memory_write_done', false )::text -from inserted; +from staged; """ rows = psql_json_lines(args, sql, db=args.canonical_db) if not rows: @@ -1365,6 +1387,8 @@ select jsonb_build_object( def audit_restore_available(args: argparse.Namespace) -> bool: + if os.environ.get("TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK", "") != "1": + return False sql = """ select bool_and(to_regclass(table_name) is not null) from (values @@ -1675,6 +1699,7 @@ def emit_markdown(value: dict[str, Any]) -> None: def main() -> None: args = parse_args() + validate_runtime_connection(args) read_loaders = { "search": lambda: query_rows(args, args.query, args.limit), "context": lambda: query_rows(args, args.query, args.limit), diff --git a/hermes-agent/leoclean-bin/teleo-kb b/hermes-agent/leoclean-bin/teleo-kb index 808c77f..369f09b 100755 --- a/hermes-agent/leoclean-bin/teleo-kb +++ b/hermes-agent/leoclean-bin/teleo-kb @@ -23,14 +23,25 @@ cloudsql_args() { } run_cloudsql() { - if cloudsql_supported; then - extra_args=() - while IFS= read -r arg; do - [ -n "$arg" ] && extra_args+=("$arg") - done < <(cloudsql_args) - exec python3 "$CLOUDSQL_TOOL" "$@" "${extra_args[@]}" + if ! cloudsql_supported; then + echo "teleo-kb: command '$COMMAND' is not supported by the Cloud SQL backend" >&2 + exit 64 fi - exec python3 "$KB_TOOL" "$@" + if [ ! -x "$CLOUDSQL_TOOL" ]; then + echo "teleo-kb: Cloud SQL tool is missing or not executable: $CLOUDSQL_TOOL" >&2 + exit 69 + fi + for dependency in python3 psql gcloud; do + if ! command -v "$dependency" >/dev/null 2>&1; then + echo "teleo-kb: required Cloud SQL dependency is unavailable: $dependency" >&2 + exit 69 + fi + done + extra_args=() + while IFS= read -r arg; do + [ -n "$arg" ] && extra_args+=("$arg") + done < <(cloudsql_args) + exec python3 "$CLOUDSQL_TOOL" "$@" "${extra_args[@]}" } case "$MODE" in @@ -46,8 +57,8 @@ case "$MODE" in auto) # Canonical commands must fail closed on Cloud SQL errors. Falling through # to a different local database would make source-of-truth behavior depend - # on health-check latency. - if [ -x "$CLOUDSQL_TOOL" ] && command -v psql >/dev/null 2>&1 && command -v gcloud >/dev/null 2>&1 && cloudsql_supported; then + # on health-check latency or host package state. + if cloudsql_supported; then run_cloudsql "$@" fi if command -v docker >/dev/null 2>&1; then diff --git a/ops/gcp_leoclean_runtime_role.sql b/ops/gcp_leoclean_runtime_role.sql new file mode 100644 index 0000000..cd090a3 --- /dev/null +++ b/ops/gcp_leoclean_runtime_role.sql @@ -0,0 +1,847 @@ +\set ON_ERROR_STOP on +\getenv runtime_password TELEO_LEOCLEAN_DB_PASSWORD + +-- One-time GCP staging provisioning for Leo's Cloud SQL identity. The password +-- is supplied only through the process environment and is never stored here. +-- +-- The login can read the canonical allowlist and can stage a pending proposal +-- through one narrowly-scoped SECURITY DEFINER function. It cannot inherit or +-- SET ROLE into another principal, forge the proposer identity, write directly +-- to canonical/staging tables, or inspect the legacy teleo_restore audit schema. +select 'create role leoclean_kb_runtime login nosuperuser nocreatedb nocreaterole noinherit noreplication nobypassrls connection limit 8' +where not exists (select 1 from pg_catalog.pg_roles where rolname = 'leoclean_kb_runtime') +\gexec + +select 'create role leoclean_kb_stage_owner nologin nosuperuser nocreatedb nocreaterole noinherit noreplication nobypassrls connection limit -1' +where not exists (select 1 from pg_catalog.pg_roles where rolname = 'leoclean_kb_stage_owner') +\gexec + +select format('alter role leoclean_kb_runtime password %L', :'runtime_password') +\gexec + +alter role leoclean_kb_runtime + with login nosuperuser nocreatedb nocreaterole noinherit noreplication nobypassrls connection limit 8; +alter role leoclean_kb_stage_owner + with nologin nosuperuser nocreatedb nocreaterole noinherit noreplication nobypassrls connection limit -1 + password null; + +alter role leoclean_kb_runtime reset all; +alter role leoclean_kb_stage_owner reset all; + +-- NOINHERIT does not prevent SET ROLE. Remove every membership edge in either +-- direction so neither scoped principal can reach, or be reached through, a +-- broader role left behind by an earlier deployment. +select format('revoke %I from %I', granted_role.rolname, member_role.rolname) + from pg_catalog.pg_auth_members membership + join pg_catalog.pg_roles granted_role on granted_role.oid = membership.roleid + join pg_catalog.pg_roles member_role on member_role.oid = membership.member + where granted_role.rolname in ('leoclean_kb_runtime', 'leoclean_kb_stage_owner') + or member_role.rolname in ('leoclean_kb_runtime', 'leoclean_kb_stage_owner') +\gexec + +revoke all privileges on database teleo_canonical from leoclean_kb_runtime; +revoke all privileges on database teleo_canonical from leoclean_kb_stage_owner; +revoke all privileges on database teleo_kb from leoclean_kb_runtime; +revoke all privileges on database teleo_kb from leoclean_kb_stage_owner; +grant connect on database teleo_canonical to leoclean_kb_runtime; + +-- PostgreSQL normally grants TEMP to PUBLIC. A role-specific REVOKE cannot +-- override that inherited grant, while REVOKE TEMP FROM PUBLIC would be a +-- database-wide change that could break kb_apply/review or operator workflows. +-- Leave PUBLIC TEMP policy unchanged here and surface it in the final receipt. +-- TEMP cannot shadow this SECURITY DEFINER boundary: pg_catalog is first, +-- pg_temp is explicitly last, and every relation reference is schema-qualified. +-- Database CREATE, direct TEMP grants, and all unexpected direct database ACLs +-- are rejected below. A database-wide TEMP removal requires a separate role-use +-- inventory and explicit grants to every workload that still needs it. + +alter role leoclean_kb_runtime in database teleo_canonical reset all; +alter role leoclean_kb_stage_owner in database teleo_canonical reset all; +alter role leoclean_kb_runtime in database teleo_kb reset all; +alter role leoclean_kb_stage_owner in database teleo_kb reset all; + +alter role leoclean_kb_runtime in database teleo_canonical + set search_path = pg_catalog, public, kb_stage; +alter role leoclean_kb_runtime in database teleo_canonical + set statement_timeout = '15s'; +alter role leoclean_kb_runtime in database teleo_canonical + set lock_timeout = '2s'; + +\connect teleo_canonical + +begin; + +-- The deploy principal needs temporary ownership authority to replace an +-- existing function on idempotent reruns. This membership is transaction-local +-- and is revoked before verification/commit. +select format('grant leoclean_kb_stage_owner to %I', current_user) + where current_user <> 'leoclean_kb_stage_owner' +\gexec + +revoke all on schema public, kb_stage + from leoclean_kb_runtime, leoclean_kb_stage_owner; +revoke all privileges on all tables in schema public, kb_stage + from leoclean_kb_runtime, leoclean_kb_stage_owner; +revoke all privileges on all sequences in schema public, kb_stage + from leoclean_kb_runtime, leoclean_kb_stage_owner; +revoke all privileges on all functions in schema public, kb_stage + from leoclean_kb_runtime, leoclean_kb_stage_owner; +revoke all privileges on all procedures in schema public, kb_stage + from leoclean_kb_runtime, leoclean_kb_stage_owner; + +-- Table-level REVOKE does not clear column ACLs. Clear every column ACL for both +-- principals before rebuilding the exact allowlist below. +select format( + 'revoke all privileges (%s) on table %I.%I from %I', + pg_catalog.string_agg(pg_catalog.quote_ident(attribute.attname), ', ' order by attribute.attnum), + namespace.nspname, + relation.relname, + target.rolname + ) + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + cross join ( + values ('leoclean_kb_runtime'), ('leoclean_kb_stage_owner') + ) as target(rolname) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + group by namespace.nspname, relation.relname, target.rolname +\gexec + +grant usage on schema public, kb_stage to leoclean_kb_runtime; + +grant select on + public.agents, + public.claims, + public.claim_evidence, + public.claim_edges, + public.sources, + public.personas, + public.strategies, + public.beliefs, + public.blindspots, + public.agent_roles, + public.peer_models, + public.behavioral_rules, + public.contributor_rules, + public.reasoning_tools, + public.governance_gates, + kb_stage.kb_proposals +to leoclean_kb_runtime; + +-- The function owner is a dedicated NOLOGIN role. Grant only the columns the +-- function reads/inserts. CREATE is temporary and is revoked immediately after +-- ownership is assigned. +grant usage, create on schema kb_stage to leoclean_kb_stage_owner; +grant usage on schema public to leoclean_kb_stage_owner; +grant select (id, handle) on public.agents to leoclean_kb_stage_owner; +grant select on kb_stage.kb_proposals to leoclean_kb_stage_owner; +grant insert ( + proposal_type, + status, + proposed_by_handle, + proposed_by_agent_id, + channel, + source_ref, + rationale, + payload +) on kb_stage.kb_proposals to leoclean_kb_stage_owner; + +-- Remove the prior caller-supplied-identity overload before installing the +-- session-bound form. A runtime caller can only stage as canonical agent Leo. +drop function if exists kb_stage.stage_leoclean_proposal(text, text, text, text, text, jsonb); + +create or replace function kb_stage.stage_leoclean_proposal( + p_proposal_type text, + p_channel text, + p_source_ref text, + p_rationale text, + p_payload jsonb +) returns jsonb +language plpgsql +security definer +set search_path = pg_catalog, pg_temp +as $function$ +declare + staged kb_stage.kb_proposals%rowtype; + proposer_id uuid; +begin + if session_user <> 'leoclean_kb_runtime' then + raise exception 'stage_leoclean_proposal is restricted to leoclean_kb_runtime' + using errcode = '42501'; + end if; + if p_proposal_type not in ('revise_claim', 'revise_strategy', 'add_edge') then + raise exception 'unsupported Leo proposal type: %', p_proposal_type using errcode = '22023'; + end if; + if nullif(btrim(p_source_ref), '') is null then + raise exception 'source_ref is required' using errcode = '22023'; + end if; + if nullif(btrim(p_rationale), '') is null then + raise exception 'rationale is required' using errcode = '22023'; + end if; + if p_payload is null or jsonb_typeof(p_payload) <> 'object' then + raise exception 'proposal payload must be a JSON object' using errcode = '22023'; + end if; + + select agent.id + into proposer_id + from public.agents as agent + where agent.handle = 'leo'; + + if proposer_id is null then + raise exception 'canonical Leo agent row is required' using errcode = '23503'; + end if; + + insert into kb_stage.kb_proposals ( + proposal_type, + status, + proposed_by_handle, + proposed_by_agent_id, + channel, + source_ref, + rationale, + payload + ) values ( + p_proposal_type, + 'pending_review', + 'leo', + proposer_id, + coalesce(nullif(p_channel, ''), 'telegram'), + p_source_ref, + p_rationale, + p_payload + ) + returning * into staged; + + return to_jsonb(staged); +end +$function$; + +alter function kb_stage.stage_leoclean_proposal(text, text, text, text, jsonb) + owner to leoclean_kb_stage_owner; +revoke create on schema kb_stage from leoclean_kb_stage_owner; + +-- Normalize explicit ACL entries left by CREATE OR REPLACE or an earlier grant. +revoke all on function kb_stage.stage_leoclean_proposal(text, text, text, text, jsonb) + from public; +select format( + 'revoke all on function kb_stage.stage_leoclean_proposal(text, text, text, text, jsonb) from %I', + grantee.rolname + ) + from pg_catalog.pg_proc function_row + cross join lateral pg_catalog.aclexplode( + coalesce( + function_row.proacl, + pg_catalog.acldefault('f', function_row.proowner) + ) + ) as acl + join pg_catalog.pg_roles grantee on grantee.oid = acl.grantee + where function_row.oid = 'kb_stage.stage_leoclean_proposal(text,text,text,text,jsonb)'::pg_catalog.regprocedure + and grantee.rolname <> 'leoclean_kb_stage_owner' +\gexec + +grant execute on function kb_stage.stage_leoclean_proposal(text, text, text, text, jsonb) + to leoclean_kb_runtime; + +select format('revoke leoclean_kb_stage_owner from %I', current_user) + where current_user <> 'leoclean_kb_stage_owner' +\gexec + +do $verification$ +declare + unexpected text; + runtime_oid oid := 'leoclean_kb_runtime'::pg_catalog.regrole::oid; + owner_oid oid := 'leoclean_kb_stage_owner'::pg_catalog.regrole::oid; + stage_function oid := 'kb_stage.stage_leoclean_proposal(text,text,text,text,jsonb)'::pg_catalog.regprocedure::oid; +begin + if exists ( + select 1 + from pg_catalog.pg_roles role_row + where role_row.rolname = 'leoclean_kb_runtime' + and (not role_row.rolcanlogin + or role_row.rolsuper + or role_row.rolcreatedb + or role_row.rolcreaterole + or role_row.rolinherit + or role_row.rolreplication + or role_row.rolbypassrls + or role_row.rolconnlimit <> 8) + ) then + raise exception 'leoclean_kb_runtime has unsafe role attributes'; + end if; + + if exists ( + select 1 + from pg_catalog.pg_roles role_row + where role_row.rolname = 'leoclean_kb_stage_owner' + and (role_row.rolcanlogin + or role_row.rolsuper + or role_row.rolcreatedb + or role_row.rolcreaterole + or role_row.rolinherit + or role_row.rolreplication + or role_row.rolbypassrls + or role_row.rolconnlimit <> -1) + ) then + raise exception 'leoclean_kb_stage_owner has unsafe role attributes'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I->%I', granted_role.rolname, member_role.rolname), + ', ' + order by granted_role.rolname, member_role.rolname + ) + into unexpected + from pg_catalog.pg_auth_members membership + join pg_catalog.pg_roles granted_role on granted_role.oid = membership.roleid + join pg_catalog.pg_roles member_role on member_role.oid = membership.member + where membership.roleid in (runtime_oid, owner_oid) + or membership.member in (runtime_oid, owner_oid); + if unexpected is not null then + raise exception 'scoped Leo roles retain role memberships: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format( + '%I:%s:%s:grantable=%s', + database_row.datname, + grantee.rolname, + acl.privilege_type, + acl.is_grantable + ), + ', ' + order by database_row.datname, grantee.rolname, acl.privilege_type + ) + into unexpected + from pg_catalog.pg_database database_row + cross join lateral pg_catalog.aclexplode( + coalesce( + database_row.datacl, + pg_catalog.acldefault('d', database_row.datdba) + ) + ) as acl + join pg_catalog.pg_roles grantee on grantee.oid = acl.grantee + where database_row.datname in ('teleo_canonical', 'teleo_kb') + and grantee.rolname in ('leoclean_kb_runtime', 'leoclean_kb_stage_owner') + and not ( + database_row.datname = 'teleo_canonical' + and grantee.rolname = 'leoclean_kb_runtime' + and acl.privilege_type = 'CONNECT' + and not acl.is_grantable + ); + if unexpected is not null then + raise exception 'scoped Leo roles retain unexpected direct database ACLs: %', unexpected; + end if; + + if has_database_privilege('leoclean_kb_runtime', 'teleo_canonical', 'CREATE') + or has_database_privilege('leoclean_kb_runtime', 'teleo_kb', 'CREATE') + or has_database_privilege('leoclean_kb_stage_owner', 'teleo_canonical', 'CREATE') + or has_database_privilege('leoclean_kb_stage_owner', 'teleo_kb', 'CREATE') then + raise exception 'a scoped Leo role unexpectedly has database CREATE'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%s:%s:%s', dependency.classid::pg_catalog.regclass, dependency.objid, dependency.objsubid), + ', ' + ) + into unexpected + from pg_catalog.pg_shdepend dependency + where dependency.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass + and dependency.refobjid = runtime_oid + and dependency.deptype = 'o'; + if unexpected is not null then + raise exception 'leoclean_kb_runtime unexpectedly owns database objects: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%s:%s:%s', dependency.classid::pg_catalog.regclass, dependency.objid, dependency.objsubid), + ', ' + ) + into unexpected + from pg_catalog.pg_shdepend dependency + where dependency.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass + and dependency.refobjid = owner_oid + and dependency.deptype = 'o' + and not ( + dependency.dbid = (select database_row.oid from pg_catalog.pg_database database_row where database_row.datname = current_database()) + and + dependency.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + and dependency.objid = stage_function + and dependency.objsubid = 0 + ); + if unexpected is not null then + raise exception 'leoclean_kb_stage_owner owns unexpected database objects: %', unexpected; + end if; + + if has_schema_privilege('leoclean_kb_runtime', 'public', 'CREATE') + or has_schema_privilege('leoclean_kb_runtime', 'kb_stage', 'CREATE') + or has_schema_privilege('leoclean_kb_stage_owner', 'public', 'CREATE') + or has_schema_privilege('leoclean_kb_stage_owner', 'kb_stage', 'CREATE') then + raise exception 'a scoped Leo role unexpectedly has schema CREATE'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I:%s', namespace.nspname, relation.relname, privilege.name), + ', ' + order by namespace.nspname, relation.relname, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join (values ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER')) privilege(name) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and has_table_privilege('leoclean_kb_runtime', relation.oid, privilege.name); + if unexpected is not null then + raise exception 'leoclean_kb_runtime unexpectedly has table-level DML: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I.%I:%s', namespace.nspname, relation.relname, attribute.attname, privilege.name), + ', ' + order by namespace.nspname, relation.relname, attribute.attnum, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + cross join (values ('INSERT'), ('UPDATE'), ('REFERENCES')) privilege(name) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + and has_column_privilege('leoclean_kb_runtime', relation.oid, attribute.attnum, privilege.name); + if unexpected is not null then + raise exception 'leoclean_kb_runtime unexpectedly has column-level DML: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I', namespace.nspname, relation.relname), + ', ' + order by namespace.nspname, relation.relname + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and has_table_privilege('leoclean_kb_runtime', relation.oid, 'SELECT') + and (namespace.nspname, relation.relname) not in ( + ('public', 'agents'), + ('public', 'claims'), + ('public', 'claim_evidence'), + ('public', 'claim_edges'), + ('public', 'sources'), + ('public', 'personas'), + ('public', 'strategies'), + ('public', 'beliefs'), + ('public', 'blindspots'), + ('public', 'agent_roles'), + ('public', 'peer_models'), + ('public', 'behavioral_rules'), + ('public', 'contributor_rules'), + ('public', 'reasoning_tools'), + ('public', 'governance_gates'), + ('kb_stage', 'kb_proposals') + ); + if unexpected is not null then + raise exception 'leoclean_kb_runtime can SELECT outside its table allowlist: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I.%I', namespace.nspname, relation.relname, attribute.attname), + ', ' + order by namespace.nspname, relation.relname, attribute.attnum + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + and has_column_privilege('leoclean_kb_runtime', relation.oid, attribute.attnum, 'SELECT') + and (namespace.nspname, relation.relname) not in ( + ('public', 'agents'), + ('public', 'claims'), + ('public', 'claim_evidence'), + ('public', 'claim_edges'), + ('public', 'sources'), + ('public', 'personas'), + ('public', 'strategies'), + ('public', 'beliefs'), + ('public', 'blindspots'), + ('public', 'agent_roles'), + ('public', 'peer_models'), + ('public', 'behavioral_rules'), + ('public', 'contributor_rules'), + ('public', 'reasoning_tools'), + ('public', 'governance_gates'), + ('kb_stage', 'kb_proposals') + ); + if unexpected is not null then + raise exception 'leoclean_kb_runtime has column SELECT outside its allowlist: %', unexpected; + end if; + + if has_table_privilege('leoclean_kb_stage_owner', 'public.agents', 'SELECT') + or not has_column_privilege('leoclean_kb_stage_owner', 'public.agents', 'id', 'SELECT') + or not has_column_privilege('leoclean_kb_stage_owner', 'public.agents', 'handle', 'SELECT') + or not has_table_privilege('leoclean_kb_stage_owner', 'kb_stage.kb_proposals', 'SELECT') + or has_table_privilege('leoclean_kb_stage_owner', 'kb_stage.kb_proposals', 'INSERT') then + raise exception 'leoclean_kb_stage_owner does not have the exact read/insert grant shape'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I.%I', namespace.nspname, relation.relname, attribute.attname), + ', ' + order by namespace.nspname, relation.relname, attribute.attnum + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + and has_column_privilege('leoclean_kb_stage_owner', relation.oid, attribute.attnum, 'INSERT') + and not ( + namespace.nspname = 'kb_stage' + and relation.relname = 'kb_proposals' + and attribute.attname in ( + 'proposal_type', + 'status', + 'proposed_by_handle', + 'proposed_by_agent_id', + 'channel', + 'source_ref', + 'rationale', + 'payload' + ) + ); + if unexpected is not null then + raise exception 'leoclean_kb_stage_owner can INSERT unexpected columns: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I:%s', namespace.nspname, relation.relname, privilege.name), + ', ' + order by namespace.nspname, relation.relname, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join (values ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER')) privilege(name) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and has_table_privilege('leoclean_kb_stage_owner', relation.oid, privilege.name); + if unexpected is not null then + raise exception 'leoclean_kb_stage_owner unexpectedly has table-level DML: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I.%I:%s', namespace.nspname, relation.relname, attribute.attname, privilege.name), + ', ' + order by namespace.nspname, relation.relname, attribute.attnum, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + cross join (values ('UPDATE'), ('REFERENCES')) privilege(name) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + and has_column_privilege('leoclean_kb_stage_owner', relation.oid, attribute.attnum, privilege.name); + if unexpected is not null then + raise exception 'leoclean_kb_stage_owner unexpectedly has column-level DML: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I.%I', namespace.nspname, relation.relname, attribute.attname), + ', ' + order by namespace.nspname, relation.relname, attribute.attnum + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + and has_column_privilege('leoclean_kb_stage_owner', relation.oid, attribute.attnum, 'SELECT') + and not ( + (namespace.nspname = 'kb_stage' and relation.relname = 'kb_proposals') + or ( + namespace.nspname = 'public' + and relation.relname = 'agents' + and attribute.attname in ('id', 'handle') + ) + ); + if unexpected is not null then + raise exception 'leoclean_kb_stage_owner can SELECT unexpected columns: %', unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I:%s:%s', namespace.nspname, relation.relname, scoped_role.name, privilege.name), + ', ' + order by namespace.nspname, relation.relname, scoped_role.name, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join (values ('leoclean_kb_runtime'), ('leoclean_kb_stage_owner')) scoped_role(name) + cross join (values ('USAGE'), ('SELECT'), ('UPDATE')) privilege(name) + where namespace.nspname in ('public', 'kb_stage') + and relation.relkind = 'S' + and has_sequence_privilege(scoped_role.name, relation.oid, privilege.name); + if unexpected is not null then + raise exception 'a scoped Leo role unexpectedly has sequence privileges: %', unexpected; + end if; + + if exists ( + select 1 + from pg_catalog.pg_attribute attribute + where attribute.attrelid = 'kb_stage.kb_proposals'::pg_catalog.regclass + and attribute.attname in ( + 'proposal_type', + 'status', + 'proposed_by_handle', + 'proposed_by_agent_id', + 'channel', + 'source_ref', + 'rationale', + 'payload' + ) + and not has_column_privilege( + 'leoclean_kb_stage_owner', + attribute.attrelid, + attribute.attnum, + 'INSERT' + ) + ) then + raise exception 'leoclean_kb_stage_owner is missing a required INSERT column grant'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I(%s)', namespace.nspname, function_row.proname, pg_catalog.pg_get_function_identity_arguments(function_row.oid)), + ', ' + ) + into unexpected + from pg_catalog.pg_proc function_row + join pg_catalog.pg_namespace namespace on namespace.oid = function_row.pronamespace + where namespace.nspname in ('public', 'kb_stage') + and function_row.prosecdef + and function_row.oid <> stage_function + and has_function_privilege('leoclean_kb_runtime', function_row.oid, 'EXECUTE'); + if unexpected is not null then + raise exception 'leoclean_kb_runtime can execute unexpected SECURITY DEFINER functions: %', unexpected; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_proc function_row + where function_row.oid = stage_function + and function_row.prosecdef + and function_row.proowner = owner_oid + and function_row.proconfig = array['search_path=pg_catalog, pg_temp']::text[] + ) then + raise exception 'stage_leoclean_proposal owner/security/search_path contract is invalid'; + end if; + + select pg_catalog.string_agg( + pg_catalog.format( + 'grantee=%s privilege=%s grantable=%s', + acl.grantee, + acl.privilege_type, + acl.is_grantable + ), + ', ' + ) + into unexpected + from pg_catalog.pg_proc function_row + cross join lateral pg_catalog.aclexplode( + coalesce( + function_row.proacl, + pg_catalog.acldefault('f', function_row.proowner) + ) + ) as acl + where function_row.oid = stage_function + and not ( + acl.grantee in (runtime_oid, owner_oid) + and acl.privilege_type = 'EXECUTE' + and (acl.grantee = owner_oid or not acl.is_grantable) + ); + if unexpected is not null then + raise exception 'stage_leoclean_proposal has unexpected ACL entries: %', unexpected; + end if; + + if not has_function_privilege( + 'leoclean_kb_runtime', + 'kb_stage.stage_leoclean_proposal(text,text,text,text,jsonb)', + 'EXECUTE' + ) then + raise exception 'leoclean_kb_runtime cannot execute stage_leoclean_proposal'; + end if; +end +$verification$; + +commit; + +-- The legacy restore database is not an audit fallback for the scoped runtime. +-- Strip direct ACLs from both scoped roles and fail if PUBLIC/group grants still +-- make any teleo_restore relation reachable. +\connect teleo_kb + +begin; + +select format('revoke all on schema %I from leoclean_kb_runtime, leoclean_kb_stage_owner', namespace.nspname) + from pg_catalog.pg_namespace namespace + where namespace.nspname = 'teleo_restore' +\gexec + +select format('revoke all privileges on all tables in schema %I from leoclean_kb_runtime, leoclean_kb_stage_owner', namespace.nspname) + from pg_catalog.pg_namespace namespace + where namespace.nspname = 'teleo_restore' +\gexec + +select format('revoke all privileges on all sequences in schema %I from leoclean_kb_runtime, leoclean_kb_stage_owner', namespace.nspname) + from pg_catalog.pg_namespace namespace + where namespace.nspname = 'teleo_restore' +\gexec + +select format('revoke all privileges on all functions in schema %I from leoclean_kb_runtime, leoclean_kb_stage_owner', namespace.nspname) + from pg_catalog.pg_namespace namespace + where namespace.nspname = 'teleo_restore' +\gexec + +select format('revoke all privileges on all procedures in schema %I from leoclean_kb_runtime, leoclean_kb_stage_owner', namespace.nspname) + from pg_catalog.pg_namespace namespace + where namespace.nspname = 'teleo_restore' +\gexec + +select format( + 'revoke all privileges (%s) on table %I.%I from %I', + pg_catalog.string_agg(pg_catalog.quote_ident(attribute.attname), ', ' order by attribute.attnum), + namespace.nspname, + relation.relname, + target.rolname + ) + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + cross join ( + values ('leoclean_kb_runtime'), ('leoclean_kb_stage_owner') + ) as target(rolname) + where namespace.nspname = 'teleo_restore' + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + group by namespace.nspname, relation.relname, target.rolname +\gexec + +do $audit_verification$ +declare + unexpected text; + scoped_role text; +begin + foreach scoped_role in array array['leoclean_kb_runtime', 'leoclean_kb_stage_owner'] loop + if to_regnamespace('teleo_restore') is not null + and (has_schema_privilege(scoped_role, 'teleo_restore', 'USAGE') + or has_schema_privilege(scoped_role, 'teleo_restore', 'CREATE')) then + raise exception '% unexpectedly has teleo_restore schema access', scoped_role; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I:%s', namespace.nspname, relation.relname, privilege.name), + ', ' + order by relation.relname, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join ( + values ('SELECT'), ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER') + ) privilege(name) + where namespace.nspname = 'teleo_restore' + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and has_table_privilege(scoped_role, relation.oid, privilege.name); + if unexpected is not null then + raise exception '% unexpectedly has teleo_restore table access: %', scoped_role, unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I.%I:%s', namespace.nspname, relation.relname, attribute.attname, privilege.name), + ', ' + order by relation.relname, attribute.attnum, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_catalog.pg_attribute attribute on attribute.attrelid = relation.oid + cross join (values ('SELECT'), ('INSERT'), ('UPDATE'), ('REFERENCES')) privilege(name) + where namespace.nspname = 'teleo_restore' + and relation.relkind in ('r', 'p', 'v', 'm', 'f') + and attribute.attnum > 0 + and not attribute.attisdropped + and has_column_privilege(scoped_role, relation.oid, attribute.attnum, privilege.name); + if unexpected is not null then + raise exception '% unexpectedly has teleo_restore column access: %', scoped_role, unexpected; + end if; + + select pg_catalog.string_agg( + pg_catalog.format('%I.%I:%s', namespace.nspname, relation.relname, privilege.name), + ', ' + order by relation.relname, privilege.name + ) + into unexpected + from pg_catalog.pg_class relation + join pg_catalog.pg_namespace namespace on namespace.oid = relation.relnamespace + cross join (values ('USAGE'), ('SELECT'), ('UPDATE')) privilege(name) + where namespace.nspname = 'teleo_restore' + and relation.relkind = 'S' + and has_sequence_privilege(scoped_role, relation.oid, privilege.name); + if unexpected is not null then + raise exception '% unexpectedly has teleo_restore sequence access: %', scoped_role, unexpected; + end if; + end loop; +end +$audit_verification$; + +commit; + +\connect teleo_canonical + +select jsonb_build_object( + 'role', rolname, + 'superuser', rolsuper, + 'create_db', rolcreatedb, + 'create_role', rolcreaterole, + 'inherit', rolinherit, + 'replication', rolreplication, + 'bypass_rls', rolbypassrls, + 'has_role_memberships', exists ( + select 1 + from pg_catalog.pg_auth_members membership + where membership.roleid = pg_catalog.pg_roles.oid + or membership.member = pg_catalog.pg_roles.oid + ), + 'can_select_claims', has_table_privilege(rolname, 'public.claims', 'SELECT'), + 'can_select_proposals', has_table_privilege(rolname, 'kb_stage.kb_proposals', 'SELECT'), + 'can_insert_proposals_directly', has_table_privilege(rolname, 'kb_stage.kb_proposals', 'INSERT'), + 'can_create_database_objects', has_database_privilege(rolname, 'teleo_canonical', 'CREATE'), + 'can_create_temp_objects', has_database_privilege(rolname, 'teleo_canonical', 'TEMP'), + 'can_stage_proposal', has_function_privilege( + rolname, + 'kb_stage.stage_leoclean_proposal(text,text,text,text,jsonb)', + 'EXECUTE' + ) +)::text +from pg_catalog.pg_roles +where rolname = 'leoclean_kb_runtime'; diff --git a/scripts/run_gcp_generated_db_direct_claim_suite.py b/scripts/run_gcp_generated_db_direct_claim_suite.py index 79b0704..f2a3e31 100644 --- a/scripts/run_gcp_generated_db_direct_claim_suite.py +++ b/scripts/run_gcp_generated_db_direct_claim_suite.py @@ -47,7 +47,7 @@ DEFAULT_HOST = "10.61.0.3" DEFAULT_PROJECT = "teleo-501523" DEFAULT_SECRET = "gcp-teleo-pgvector-standby-postgres-password" DEFAULT_MANIFEST_SQL = HERE.parent / "ops" / "postgres_parity_manifest.sql" -REVIEWED_CLOUDSQL_TOOL_SHA256 = "7d2074a0fc5f0d48fbf8f7905a72ead8f2696c86a041fa43e078fff5500baa51" +REVIEWED_CLOUDSQL_TOOL_SHA256 = "2617bb501456758131ff41a6c77b3c462e21c28c4348da319b60defb051c7c87" REVIEWED_MANIFEST_SQL_SHA256 = "8b8cdc25d54fdd8de05eb38c6e4423d2836953eb6012d4545f5c9c71b5f0150a" COUNT_READBACK_RE = re.compile( r"DB readback:\s*claims:\s*`?(?P\d+)`?;\s*" @@ -416,6 +416,8 @@ $PYTHON "$CLOUDSQL_TOOL" \ --db "$TARGET_DATABASE" \ --canonical-db "$TARGET_DATABASE" \ --project "$PROJECT" \ + --credential-mode clone-readonly \ + --user postgres \ --password-secret "$PASSWORD_SECRET" \ "$@" STATUS=$? diff --git a/systemd/leoclean-gcp-prod-parallel-cloudsql.conf b/systemd/leoclean-gcp-prod-parallel-cloudsql.conf new file mode 100644 index 0000000..1b267f5 --- /dev/null +++ b/systemd/leoclean-gcp-prod-parallel-cloudsql.conf @@ -0,0 +1,14 @@ +[Service] +UnsetEnvironment=PGPASSWORD +RuntimeDirectory=leoclean-gcp-prod-parallel +RuntimeDirectoryMode=0700 +Environment=CLOUDSDK_CONFIG=/run/leoclean-gcp-prod-parallel +Environment=TELEO_KB_MODE=cloudsql +Environment=TELEO_GCP_PROJECT=teleo-501523 +Environment=TELEO_CLOUDSQL_HOST=10.61.0.3 +Environment=TELEO_CLOUDSQL_PORT=5432 +Environment=TELEO_CLOUDSQL_DB=teleo_canonical +Environment=TELEO_CANONICAL_CLOUDSQL_DB=teleo_canonical +Environment=TELEO_CLOUDSQL_USER=leoclean_kb_runtime +Environment=TELEO_CLOUDSQL_PASSWORD_SECRET=gcp-teleo-pgvector-standby-leoclean-kb-runtime-password +Environment=TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK=0 diff --git a/tests/test_gcp_generated_db_direct_claim_suite.py b/tests/test_gcp_generated_db_direct_claim_suite.py index 18ce5d0..475b8e7 100644 --- a/tests/test_gcp_generated_db_direct_claim_suite.py +++ b/tests/test_gcp_generated_db_direct_claim_suite.py @@ -115,6 +115,8 @@ def test_wrapper_binds_private_read_only_database_and_records_calls(tmp_path: Pa assert "dbname=$TARGET_DATABASE" in wrapper assert '--db "$TARGET_DATABASE"' in wrapper assert '--canonical-db "$TARGET_DATABASE"' in wrapper + assert "--credential-mode clone-readonly" in wrapper + assert "--user postgres" in wrapper assert "sslmode=require" in wrapper assert "begin transaction read only" in wrapper assert 'export PGOPTIONS="-c default_transaction_read_only=on"' in wrapper @@ -306,6 +308,8 @@ def test_generated_wrapper_executes_only_clone_bound_default_read_only_tool(tmp_ "--canonical-db", "teleo_clone_test", ] + assert tool_receipt["argv"][tool_receipt["argv"].index("--credential-mode") + 1] == "clone-readonly" + assert tool_receipt["argv"][tool_receipt["argv"].index("--user") + 1] == "postgres" events = [json.loads(line) for line in log.read_text().splitlines()] assert [event["phase"] for event in events] == ["start", "end"] assert events[0]["database_identity"]["default_transaction_read_only"] == "on" diff --git a/tests/test_hermes_leoclean_kb_bridge_source.py b/tests/test_hermes_leoclean_kb_bridge_source.py index ab5a8d4..5b19a27 100644 --- a/tests/test_hermes_leoclean_kb_bridge_source.py +++ b/tests/test_hermes_leoclean_kb_bridge_source.py @@ -9,6 +9,7 @@ import io import json import os import re +import shutil import subprocess from pathlib import Path from types import SimpleNamespace @@ -23,6 +24,7 @@ from scripts.working_leo_open_ended_benchmark import ( ROOT = Path(__file__).resolve().parents[1] BRIDGE_DIR = ROOT / "hermes-agent" / "leoclean-bin" DB_CONTEXT_PLUGIN = ROOT / "hermes-agent" / "leoclean-plugins" / "vps" / "leo-db-context" / "__init__.py" +GCP_RUNTIME_ROLE_SQL = ROOT / "ops" / "gcp_leoclean_runtime_role.sql" def test_leoclean_bridge_files_are_present_and_parseable() -> None: @@ -67,7 +69,7 @@ def test_cloudsql_wrapper_supports_expected_review_gated_commands() -> None: assert "canonical apply" in cloudsql_text.lower() -def _install_wrapper_fixture(tmp_path: Path) -> tuple[Path, dict[str, str]]: +def _install_wrapper_fixture(tmp_path: Path, *, missing: frozenset[str] = frozenset()) -> tuple[Path, dict[str, str]]: profile = tmp_path / "profile" bin_dir = profile / "bin" bin_dir.mkdir(parents=True) @@ -75,11 +77,12 @@ def _install_wrapper_fixture(tmp_path: Path) -> tuple[Path, dict[str, str]]: wrapper.write_bytes((BRIDGE_DIR / "teleo-kb").read_bytes()) wrapper.chmod(0o700) cloudsql = bin_dir / "cloudsql_memory_tool.py" - cloudsql.write_text( - "import json, sys\nprint(json.dumps({'backend': 'cloudsql', 'argv': sys.argv[1:]}))\n", - encoding="utf-8", - ) - cloudsql.chmod(0o700) + if "cloudsql-tool" not in missing: + cloudsql.write_text( + "import json, sys\nprint(json.dumps({'backend': 'cloudsql', 'argv': sys.argv[1:]}))\n", + encoding="utf-8", + ) + cloudsql.chmod(0o700) local = bin_dir / "kb_tool.py" local.write_text( "import json, sys\nprint(json.dumps({'backend': 'local', 'argv': sys.argv[1:]}))\n", @@ -88,7 +91,13 @@ def _install_wrapper_fixture(tmp_path: Path) -> tuple[Path, dict[str, str]]: local.chmod(0o700) fake_bin = tmp_path / "fake-bin" fake_bin.mkdir() + for name in ("bash", "python3"): + target = shutil.which(name) + assert target + (fake_bin / name).symlink_to(target) for name in ("psql", "gcloud"): + if name in missing: + continue executable = fake_bin / name executable.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") executable.chmod(0o700) @@ -96,7 +105,7 @@ def _install_wrapper_fixture(tmp_path: Path) -> tuple[Path, dict[str, str]]: **os.environ, "HERMES_HOME": str(profile), "TELEO_KB_MODE": "auto", - "PATH": f"{fake_bin}:{os.environ['PATH']}", + "PATH": str(fake_bin), } return wrapper, env @@ -137,6 +146,40 @@ def test_cloudsql_wrapper_auto_mode_never_falls_back_after_supported_command_fai assert "local" not in completed.stdout +@pytest.mark.parametrize("missing", ["cloudsql-tool", "psql", "gcloud"]) +def test_cloudsql_wrapper_auto_mode_fails_closed_when_prerequisite_is_missing(tmp_path: Path, missing: str) -> None: + wrapper, env = _install_wrapper_fixture(tmp_path, missing=frozenset({missing})) + + completed = subprocess.run( + [str(wrapper), "search", "canonical only", "--format", "json"], + text=True, + capture_output=True, + check=False, + env=env, + ) + + assert completed.returncode == 69 + assert "local" not in completed.stdout + assert "Cloud SQL" in completed.stderr + + +def test_cloudsql_wrapper_explicit_mode_rejects_unsupported_commands_without_fallback(tmp_path: Path) -> None: + wrapper, env = _install_wrapper_fixture(tmp_path) + env["TELEO_KB_MODE"] = "cloudsql" + + completed = subprocess.run( + [str(wrapper), "prepare-source", "example.md"], + text=True, + capture_output=True, + check=False, + env=env, + ) + + assert completed.returncode == 64 + assert "local" not in completed.stdout + assert "not supported by the Cloud SQL backend" in completed.stderr + + def test_bridge_edge_proposals_stage_strict_apply_payloads() -> None: cloudsql_text = (BRIDGE_DIR / "cloudsql_memory_tool.py").read_text() vps_text = (BRIDGE_DIR / "kb_tool.py").read_text() @@ -148,6 +191,9 @@ def test_bridge_edge_proposals_stage_strict_apply_payloads() -> None: assert "'to_claim', to_row.id::text" in text assert "'edge_type', p.edge_type::text" in text + assert "kb_stage.stage_leoclean_proposal(" in cloudsql_text + assert "insert into kb_stage.kb_proposals" not in cloudsql_text.lower() + def test_bridge_source_does_not_commit_raw_secret_values() -> None: combined = "\n".join(path.read_text(errors="replace") for path in BRIDGE_DIR.iterdir() if path.is_file()) @@ -163,6 +209,206 @@ def test_bridge_source_does_not_commit_raw_secret_values() -> None: assert not re.search(pattern, combined), pattern +def test_cloudsql_runtime_requires_the_exact_scoped_identity(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") + monkeypatch.delenv("PGPASSWORD", raising=False) + + with pytest.raises(SystemExit, match="requires database user leoclean_kb_runtime"): + module.validate_runtime_connection(SimpleNamespace(credential_mode="runtime", user=None, password_secret=None)) + with pytest.raises(SystemExit, match="requires database user leoclean_kb_runtime"): + module.validate_runtime_connection( + SimpleNamespace( + credential_mode="runtime", + user="postgres", + password_secret=module.RUNTIME_PASSWORD_SECRET, + ) + ) + with pytest.raises(SystemExit, match="requires database user leoclean_kb_runtime"): + module.validate_runtime_connection( + SimpleNamespace( + credential_mode="runtime", + user=f" {module.RUNTIME_DB_USER}", + password_secret=module.RUNTIME_PASSWORD_SECRET, + ) + ) + with pytest.raises(SystemExit, match="requires scoped password secret"): + module.validate_runtime_connection( + SimpleNamespace( + credential_mode="runtime", + user=module.RUNTIME_DB_USER, + password_secret="gcp-teleo-pgvector-standby-postgres-password", + ) + ) + with pytest.raises(SystemExit, match="requires scoped password secret"): + module.validate_runtime_connection( + SimpleNamespace(credential_mode="runtime", user=module.RUNTIME_DB_USER, password_secret=None) + ) + + +def test_cloudsql_runtime_accepts_only_scoped_credentials_and_rejects_inherited_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") + args = SimpleNamespace( + credential_mode="runtime", + user=module.RUNTIME_DB_USER, + password_secret=module.RUNTIME_PASSWORD_SECRET, + ) + monkeypatch.delenv("PGPASSWORD", raising=False) + + module.validate_runtime_connection(args) + + monkeypatch.setenv("PGPASSWORD", "") + with pytest.raises(SystemExit, match="Refusing inherited PGPASSWORD"): + module.validate_runtime_connection(args) + + +def test_cloudsql_clone_mode_is_read_only_and_bound_to_one_disposable_database( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") + args = SimpleNamespace( + credential_mode="clone-readonly", + user="postgres", + password_secret=None, + canonical_db="teleo_clone_canary_123", + db="teleo_clone_canary_123", + command="status", + ) + monkeypatch.setenv("PGPASSWORD", "clone-only-password") + monkeypatch.setenv("PGOPTIONS", module.CLONE_READ_ONLY_PGOPTIONS) + + module.validate_runtime_connection(args) + assert module.password(args) == "clone-only-password" + + args.command = "propose-core-change" + with pytest.raises(SystemExit, match="read commands only"): + module.validate_runtime_connection(args) + args.command = "status" + args.canonical_db = "teleo_canonical" + with pytest.raises(SystemExit, match=r"teleo_clone_\*"): + module.validate_runtime_connection(args) + + +def test_cloudsql_psql_uses_only_explicit_pg_environment_fields(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") + args = SimpleNamespace( + credential_mode="runtime", + user=module.RUNTIME_DB_USER, + password_secret=module.RUNTIME_PASSWORD_SECRET, + host="10.61.0.3", + port="5432", + db="teleo_canonical", + canonical_db="teleo_canonical", + project="teleo-501523", + command="status", + ) + monkeypatch.delenv("PGPASSWORD", raising=False) + monkeypatch.setenv("PGHOST", "attacker.invalid") + monkeypatch.setenv("PGSERVICE", "untrusted-service") + monkeypatch.setenv("PGPASSFILE", "/tmp/untrusted-pgpass") + monkeypatch.setenv("PGOPTIONS", "-c search_path=attacker") + captured: dict[str, object] = {} + + def fake_run(command, **kwargs): + if command[0] == "gcloud": + return SimpleNamespace(returncode=0, stdout="scoped-runtime-password\n", stderr="") + captured["command"] = command + captured["kwargs"] = kwargs + return SimpleNamespace(returncode=0, stdout="ok\n", stderr="") + + monkeypatch.setattr(module.subprocess, "run", fake_run) + + assert module.run_psql(args, "select 1;") == "ok\n" + assert captured["command"] == ["psql", "-X", "-At", "-q", "-v", "ON_ERROR_STOP=1"] + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + env = kwargs["env"] + assert env["PGHOST"] == "10.61.0.3" + assert env["PGPORT"] == "5432" + assert env["PGDATABASE"] == "teleo_canonical" + assert env["PGUSER"] == module.RUNTIME_DB_USER + assert env["PGSSLMODE"] == "require" + assert env["PGCONNECT_TIMEOUT"] == "8" + assert env["PGPASSWORD"] == "scoped-runtime-password" + assert "PGOPTIONS" not in env + assert "PGSERVICE" not in env + assert "PGPASSFILE" not in env + + +def test_cloudsql_proposal_calls_leave_leo_identity_to_the_database(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") + captured: list[str] = [] + + def fake_psql_json_lines(_args, sql, db=None): + captured.append(sql) + return [{"id": "proposal-id", "database": db}] + + monkeypatch.setattr(module, "psql_json_lines", fake_psql_json_lines) + core_args = SimpleNamespace( + proposal_type="revise_claim", + target_kind="claim", + target_ref="claim-id", + current="old", + proposed="new", + evidence=[], + implication=[], + originator="", + channel="telegram", + source_ref="telegram:1", + rationale="Grounded correction", + canonical_db="teleo_canonical", + ) + edge_args = SimpleNamespace( + from_claim="11111111-1111-4111-8111-111111111111", + to_claim="22222222-2222-4222-8222-222222222222", + edge_type="supports", + channel="telegram", + source_ref="telegram:2", + rationale="Grounded relation", + canonical_db="teleo_canonical", + ) + + module.propose_core_change(core_args) + module.propose_edge(edge_args) + + assert "p.proposed_by_handle" not in captured[0] + assert "as proposed_by_handle" not in captured[0] + assert "p.proposed_by_handle" not in captured[1] + assert "as proposed_by_handle" not in captured[1] + assert "stage_leoclean_proposal(\n p.proposal_type,\n p.channel," in captured[0] + assert "stage_leoclean_proposal(\n 'add_edge',\n p.channel," in captured[1] + + +def test_gcp_runtime_role_uses_one_narrow_staging_function() -> None: + sql = GCP_RUNTIME_ROLE_SQL.read_text() + + assert "security definer" in sql.lower() + assert "stage_leoclean_proposal" in sql + assert "'pending_review'" in sql + assert "revoke all privileges on all tables in schema public, kb_stage" in sql + assert "can_insert_proposals_directly" in sql + assert "can_create_database_objects" in sql + assert "can_create_temp_objects" in sql + assert "unexpectedly has database CREATE" in sql + assert "REVOKE TEMP FROM PUBLIC would be a" in sql + assert "can execute unexpected SECURITY DEFINER functions" in sql + assert "revoke all privileges (%s) on table %I.%I from %I" in sql + assert re.search( + r"grant\s+insert\s*\([^)]*proposal_type[^)]*payload[^)]*\)\s+" + r"on\s+kb_stage\.kb_proposals\s+to\s+leoclean_kb_stage_owner", + sql, + re.IGNORECASE | re.DOTALL, + ) + assert not re.search( + r"grant\s+insert(?:\s*\([^)]*\))?\s+on\s+[^;]+\s+to\s+leoclean_kb_runtime", + sql, + re.IGNORECASE | re.DOTALL, + ) + assert "grant update" not in sql.lower() + assert "grant delete" not in sql.lower() + + def _load_module(path: Path): spec = importlib.util.spec_from_file_location(path.stem, path) assert spec and spec.loader @@ -323,11 +569,16 @@ def test_cloudsql_status_works_without_optional_audit_fallback(monkeypatch) -> N assert all("teleo_restore.response_audit" not in sql or "to_regclass" in sql for sql, _db in calls) -def test_cloudsql_zero_hit_search_stays_canonical_when_audit_is_unavailable(monkeypatch) -> None: +def test_cloudsql_zero_hit_search_stays_canonical_when_audit_is_disabled(monkeypatch) -> None: module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") canonical = {"artifact": "teleo_cloudsql_canonical_kb_context", "hit_count_total": 0, "hits": []} monkeypatch.setattr(module, "query_canonical_rows", lambda *_args: canonical.copy()) - monkeypatch.setattr(module, "audit_restore_available", lambda _args: False) + monkeypatch.delenv("TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK", raising=False) + monkeypatch.setattr( + module, + "audit_restore_available", + lambda _args: (_ for _ in ()).throw(AssertionError("audit availability must not be probed")), + ) monkeypatch.setattr( module, "query_audit_rows", @@ -337,7 +588,22 @@ def test_cloudsql_zero_hit_search_stays_canonical_when_audit_is_unavailable(monk result = module.query_rows(SimpleNamespace(), "unseen query", 8) assert result["hits"] == [] - assert "audit fallback unavailable" in result["canonical_fallback_reason"] + assert "audit fallback disabled" in result["canonical_fallback_reason"] + + +def test_cloudsql_zero_hit_search_uses_audit_only_when_explicitly_enabled(monkeypatch) -> None: + module = _load_module(BRIDGE_DIR / "cloudsql_memory_tool.py") + canonical = {"artifact": "teleo_cloudsql_canonical_kb_context", "hit_count_total": 0, "hits": []} + audit = {"artifact": "teleo_cloudsql_memory_context", "hits": [{"row_id": "audit-1"}]} + monkeypatch.setenv("TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK", "1") + monkeypatch.setattr(module, "query_canonical_rows", lambda *_args: canonical.copy()) + monkeypatch.setattr(module, "audit_restore_available", lambda _args: True) + monkeypatch.setattr(module, "query_audit_rows", lambda *_args: audit.copy()) + + result = module.query_rows(SimpleNamespace(), "unseen query", 8) + + assert result["hits"] == [{"row_id": "audit-1"}] + assert result["canonical_fallback_reason"] == "no matching canonical public/persona/strategy/belief rows" def test_vps_bridge_verifies_relative_source_artifact_against_canonical_hash(tmp_path: Path) -> None: diff --git a/tests/test_teleo_agent_systemd.py b/tests/test_teleo_agent_systemd.py index 154ac86..99c0535 100644 --- a/tests/test_teleo_agent_systemd.py +++ b/tests/test_teleo_agent_systemd.py @@ -41,7 +41,7 @@ def test_auto_deploy_prefers_github_remote_when_present(): auto_deploy = (REPO_ROOT / "deploy" / "auto-deploy.sh").read_text() assert 'DEPLOY_REMOTE="${TELEO_DEPLOY_REMOTE:-}"' in auto_deploy - assert 'remote get-url github' in auto_deploy + assert "remote get-url github" in auto_deploy assert 'git fetch "$DEPLOY_REMOTE" main' in auto_deploy assert 'git rev-parse "$DEPLOY_REMOTE/main"' in auto_deploy assert 'git merge --ff-only "$DEPLOY_REMOTE/main"' in auto_deploy @@ -50,7 +50,7 @@ def test_auto_deploy_prefers_github_remote_when_present(): def test_auto_deploy_executable_safety_net_stays_inside_synced_dirs(): auto_deploy = (REPO_ROOT / "deploy" / "auto-deploy.sh").read_text() - assert "for dir in \"$PIPELINE_DIR\" \"$TELEGRAM_DIR\" \"$DIAGNOSTICS_DIR\" \"$AGENT_STATE_DIR\"" in auto_deploy + assert 'for dir in "$PIPELINE_DIR" "$TELEGRAM_DIR" "$DIAGNOSTICS_DIR" "$AGENT_STATE_DIR"' in auto_deploy assert "find /opt/teleo-eval -maxdepth 3 -name '*.sh'" not in auto_deploy assert "backups" in auto_deploy @@ -61,7 +61,7 @@ def test_agent_healthcheck_timer_runs_both_live_telegram_agents(): assert "/opt/teleo-eval/telegram/agent_healthcheck.py" in service assert "--agents leo leo-wallet-test" in service - assert "--since \"20 min ago\"" in service + assert '--since "20 min ago"' in service assert "OnUnitActiveSec=5min" in timer assert "WantedBy=timers.target" in timer @@ -74,7 +74,7 @@ def test_deploy_scripts_install_systemd_units_and_enable_agent_healthcheck(): assert 'sudo install -m 0644 "$unit" "$SYSTEMD_DIR/$(basename "$unit")"' in auto_deploy assert "sudo systemctl daemon-reload" in auto_deploy assert "sudo systemctl enable --now teleo-agent-healthcheck.timer" in auto_deploy - assert "git diff --name-only \"$OLD_SHA\" \"$NEW_SHA\" -- systemd/teleo-agent@.service" in auto_deploy + assert 'git diff --name-only "$OLD_SHA" "$NEW_SHA" -- systemd/teleo-agent@.service' in auto_deploy assert 'VPS_SYSTEMD="/etc/systemd/system"' in manual_deploy assert "teleo-agent-healthcheck.timer" in manual_deploy @@ -100,16 +100,16 @@ def test_deploy_scripts_sync_leoclean_skills_and_restart_gateway_for_runtime_cha assert 'LEOCLEAN_BIN_DIR="/home/teleo/.hermes/profiles/leoclean/bin"' in auto_deploy assert 'LEOCLEAN_SKILLS_DIR="/home/teleo/.hermes/profiles/leoclean/skills"' in auto_deploy assert 'LEOCLEAN_PLUGINS_DIR="/home/teleo/.hermes/profiles/leoclean/plugins"' in auto_deploy - assert 'hermes-agent/leoclean-bin/' in auto_deploy - assert 'hermes-agent/leoclean-skills/vps/' in auto_deploy - assert 'hermes-agent/leoclean-plugins/vps/' in auto_deploy + assert "hermes-agent/leoclean-bin/" in auto_deploy + assert "hermes-agent/leoclean-skills/vps/" in auto_deploy + assert "hermes-agent/leoclean-plugins/vps/" in auto_deploy assert 'HERMES_PATCH_DIR="/home/teleo/.hermes/teleo-runtime-patches"' in auto_deploy assert '"status": "installed_now"' in auto_deploy - assert 'Hermes response-transform drift repaired' in auto_deploy - assert 'deploy/leoclean-gateway-restart-required.sh' in auto_deploy - assert 'TELEO_AUTO_DEPLOY_REEXECED' in auto_deploy + assert "Hermes response-transform drift repaired" in auto_deploy + assert "deploy/leoclean-gateway-restart-required.sh" in auto_deploy + assert "TELEO_AUTO_DEPLOY_REEXECED" in auto_deploy assert 'exec bash "$DEPLOY_CHECKOUT/deploy/auto-deploy.sh"' in auto_deploy - assert 'add_restart_if_unit_active leoclean-gateway' in auto_deploy + assert "add_restart_if_unit_active leoclean-gateway" in auto_deploy assert 'sudo systemctl restart "$svc"' in auto_deploy assert "sudo systemctl restart $RESTART" not in auto_deploy assert "NOPASSWD: /usr/bin/systemctl restart leoclean-gateway" in sudoers @@ -117,11 +117,11 @@ def test_deploy_scripts_sync_leoclean_skills_and_restart_gateway_for_runtime_cha assert 'VPS_LEOCLEAN_BIN="/home/teleo/.hermes/profiles/leoclean/bin"' in manual_deploy assert 'VPS_LEOCLEAN_SKILLS="/home/teleo/.hermes/profiles/leoclean/skills"' in manual_deploy assert 'VPS_LEOCLEAN_PLUGINS="/home/teleo/.hermes/profiles/leoclean/plugins"' in manual_deploy - assert 'hermes-agent/leoclean-bin/' in manual_deploy - assert 'hermes-agent/leoclean-skills/vps/' in manual_deploy - assert 'hermes-agent/leoclean-plugins/vps/' in manual_deploy + assert "hermes-agent/leoclean-bin/" in manual_deploy + assert "hermes-agent/leoclean-skills/vps/" in manual_deploy + assert "hermes-agent/leoclean-plugins/vps/" in manual_deploy assert 'VPS_HERMES_PATCHES="/home/teleo/.hermes/teleo-runtime-patches"' in manual_deploy - assert 'systemctl is-active --quiet leoclean-gateway.service' in manual_deploy + assert "systemctl is-active --quiet leoclean-gateway.service" in manual_deploy def test_deploy_scripts_syntax_check_leoclean_database_runtime_before_restart(): @@ -135,32 +135,22 @@ def test_deploy_scripts_syntax_check_leoclean_database_runtime_before_restart(): def test_auto_deploy_hot_syncs_leoclean_markdown_without_gateway_restart(): - assert not _leoclean_gateway_restart_required( - "hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md" - ) - assert not _leoclean_gateway_restart_required( - "hermes-agent/leoclean-skills/vps/live-leo-telegram/SKILL.md" - ) + assert not _leoclean_gateway_restart_required("hermes-agent/leoclean-skills/vps/teleo-kb-bridge/SKILL.md") + assert not _leoclean_gateway_restart_required("hermes-agent/leoclean-skills/vps/live-leo-telegram/SKILL.md") def test_auto_deploy_restarts_gateway_for_leoclean_runtime_changes(): assert _leoclean_gateway_restart_required("hermes-agent/leoclean-bin/kb_tool.py") - assert _leoclean_gateway_restart_required( - "hermes-agent/leoclean-skills/vps/teleo-kb-bridge/runtime_helper.py" - ) - assert _leoclean_gateway_restart_required( - "hermes-agent/leoclean-plugins/vps/leo-db-context/__init__.py" - ) - assert _leoclean_gateway_restart_required( - "hermes-agent/patches/apply_response_transform_hook.py" - ) + assert _leoclean_gateway_restart_required("hermes-agent/leoclean-skills/vps/teleo-kb-bridge/runtime_helper.py") + assert _leoclean_gateway_restart_required("hermes-agent/leoclean-plugins/vps/leo-db-context/__init__.py") + assert _leoclean_gateway_restart_required("hermes-agent/patches/apply_response_transform_hook.py") def test_auto_deploy_sudoers_installer_is_root_only_and_visudo_checked(): installer = (REPO_ROOT / "deploy" / "install-auto-deploy-sudoers.sh").read_text() assert 'TARGET="/etc/sudoers.d/teleo-auto-deploy"' in installer - assert 'id -u' in installer + assert "id -u" in installer assert 'install -m 0440 "$SOURCE" "$TARGET"' in installer assert 'visudo -cf "$TARGET"' in installer @@ -183,3 +173,45 @@ def test_gcp_leoclean_skill_sync_targets_parallel_runtime_without_secrets(): assert "sudo rsync" not in script assert "TELEGRAM_BOT_TOKEN" not in script assert "CLOUDSQL_PASSWORD" not in script + + +def test_gcp_runtime_sync_is_source_bound_and_scoped(): + script = (REPO_ROOT / "deploy" / "sync-gcp-leoclean-runtime.sh").read_text() + dropin = (REPO_ROOT / "systemd" / "leoclean-gcp-prod-parallel-cloudsql.conf").read_text() + + assert 'git -C "$REPO_ROOT" rev-parse HEAD' in script + assert 'git -C "$REPO_ROOT" diff --quiet HEAD' in script + assert "merge-base --is-ancestor" in script + assert "Refusing to deploy unmerged revision" in script + assert "Refusing to install runtime files without --restart" in script + assert 'DROPIN_NAME="10-cloudsql-memory.conf"' in script + assert "20-canonical-kb.conf" not in script + assert "sha256sum" in script + assert "--restart" in script + assert 'python3 "$tool_path" status --format json --redacted' in script + assert "sa-teleo-prod-vm@teleo-501523.iam.gserviceaccount.com" in script + assert "metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email" in script + assert "env -u PGPASSWORD" in script + assert 'run_scoped_status pre "$remote_tmp/$TOOL_REL"' in script + assert 'run_scoped_status post "$profile_bin/cloudsql_memory_tool.py"' in script + assert "backup_path" in script + assert "restore_path" in script + assert "rollback_runtime" in script + assert "trap 'on_exit $?' EXIT" in script + assert 'backup_path "$dropin_dir/$DROPIN_NAME" dropin' in script + assert 'dropin_stage="$dropin_dir/.${DROPIN_NAME}.livingip-new.$$"' in script + assert 'sudo mv -f -- "$dropin_stage" "$dropin_dir/$DROPIN_NAME"' in script + assert 'active_state" != "active"' in script + assert 'sub_state" != "running"' in script + assert 'sudo systemctl stop "$SERVICE"' in script + assert 'sudo systemctl start "$SERVICE"' in script + assert "UnsetEnvironment=PGPASSWORD" in dropin + assert "RuntimeDirectory=leoclean-gcp-prod-parallel" in dropin + assert "RuntimeDirectoryMode=0700" in dropin + assert "Environment=CLOUDSDK_CONFIG=/run/leoclean-gcp-prod-parallel" in dropin + assert "TELEO_KB_MODE=cloudsql" in dropin + assert "TELEO_CLOUDSQL_USER=leoclean_kb_runtime" in dropin + assert "gcp-teleo-pgvector-standby-leoclean-kb-runtime-password" in dropin + assert "TELEO_CLOUDSQL_ENABLE_AUDIT_FALLBACK=0" in dropin + assert not any(line.startswith("Environment=PGPASSWORD") for line in dropin.splitlines()) + assert "gcp-teleo-pgvector-standby-postgres-password" not in dropin