#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCHEMA_V1="${ROOT}/templates/schemas/plan-preapproval.v1.json"
SCHEMA_V2="${ROOT}/templates/schemas/plan-preapproval.v2.json"
usage() {
cat <<'EOF'
Usage:
plan-preapproval.sh validate <plan-preapprovals.json>
plan-preapproval.sh apply-secret-allow <project-root> [plan-preapprovals.json]
EOF
}
cmd_validate() {
local state="${1:-}"
if [ -z "${state}" ] || [ ! -f "${state}" ]; then
echo "validate: file not found: ${state:-<missing>}" >&2
exit 1
fi
if [ ! -f "${SCHEMA_V1}" ] || [ ! -f "${SCHEMA_V2}" ]; then
echo "validate: plan-preapproval schema is missing" >&2
exit 1
fi
python3 - "${state}" "${SCHEMA_V1}" "${SCHEMA_V2}" <<'PY'
from datetime import datetime
import json
import re
import sys
state_path, schema_v1_path, schema_v2_path = sys.argv[1:4]
with open(state_path, encoding="utf-8") as f:
data = json.load(f)
schema_version = data.get("schema_version") if isinstance(data, dict) else None
schema_paths = {
"plan-preapproval.v1": schema_v1_path,
"plan-preapproval.v2": schema_v2_path,
}
schema_path = schema_paths.get(schema_version)
if schema_path is None:
raise SystemExit(f"unsupported schema_version: {schema_version!r}")
with open(schema_path, encoding="utf-8") as f:
schema = json.load(f)
try:
import jsonschema
except Exception:
jsonschema = None
if jsonschema is not None:
jsonschema.validate(
instance=data,
schema=schema,
format_checker=jsonschema.FormatChecker(),
)
else:
rfc3339 = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?"
r"(?:Z|[+-]\d{2}:\d{2})$"
)
def require_exact_keys(obj, allowed, required, label):
unknown = set(obj) - allowed
missing = required - set(obj)
if unknown:
raise SystemExit(f"{label} has unknown fields: {sorted(unknown)}")
if missing:
raise SystemExit(f"{label} missing fields: {sorted(missing)}")
def require_nonempty_string(value, label):
if not isinstance(value, str) or not value:
raise SystemExit(f"{label} must be a non-empty string")
def require_rfc3339(value, label):
require_nonempty_string(value, label)
if not rfc3339.fullmatch(value):
raise SystemExit(f"{label} must be RFC3339")
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise SystemExit(f"{label} must be RFC3339: {exc}") from exc
if not isinstance(data, dict):
raise SystemExit("root must be object")
require_exact_keys(
data,
{"schema_version", "approved_at", "approvals"},
{"schema_version", "approved_at", "approvals"},
"root",
)
if data["schema_version"] not in schema_paths:
raise SystemExit("schema_version must be plan-preapproval.v1 or plan-preapproval.v2")
require_rfc3339(data["approved_at"], "approved_at")
approvals = data.get("approvals")
if not isinstance(approvals, list):
raise SystemExit("approvals must be an array")
allowed_ops = {"secret-read", "external-send", "destructive"}
for idx, item in enumerate(approvals):
if not isinstance(item, dict):
raise SystemExit(f"approvals[{idx}] must be object")
allowed = {
"item", "reason", "scope", "operations", "paths", "commands",
"targets", "decision", "approved_at", "approved_by",
}
required = {"item", "reason", "scope", "operations", "decision", "approved_at"}
if data["schema_version"] == "plan-preapproval.v2":
allowed |= {"expires_at", "max_uses", "uses"}
required.add("expires_at")
require_exact_keys(item, allowed, required, f"approvals[{idx}]")
require_nonempty_string(item["item"], f"approvals[{idx}].item")
require_nonempty_string(item["reason"], f"approvals[{idx}].reason")
scope = item["scope"]
if not isinstance(scope, dict):
raise SystemExit(f"approvals[{idx}].scope must be object")
require_exact_keys(
scope,
{"phase", "task"},
{"phase", "task"},
f"approvals[{idx}].scope",
)
require_nonempty_string(scope["phase"], f"approvals[{idx}].scope.phase")
require_nonempty_string(scope["task"], f"approvals[{idx}].scope.task")
ops = item["operations"]
if not isinstance(ops, list) or not ops:
raise SystemExit(f"approvals[{idx}].operations must be non-empty array")
if len(ops) != len(set(ops)):
raise SystemExit(f"approvals[{idx}].operations must be unique")
for op in ops:
if op not in allowed_ops:
raise SystemExit(f"approvals[{idx}] invalid operation {op!r}")
for key in ("paths", "commands", "targets"):
if key not in item:
continue
values = item[key]
if not isinstance(values, list):
raise SystemExit(f"approvals[{idx}].{key} must be an array")
for value in values:
require_nonempty_string(value, f"approvals[{idx}].{key} value")
if item["decision"] not in {"approved", "denied"}:
raise SystemExit(f"approvals[{idx}].decision invalid")
require_rfc3339(item["approved_at"], f"approvals[{idx}].approved_at")
if "approved_by" in item:
require_nonempty_string(item["approved_by"], f"approvals[{idx}].approved_by")
if not any(k in item for k in ("paths", "commands", "targets")):
raise SystemExit(f"approvals[{idx}] must include paths, commands, or targets")
if data["schema_version"] == "plan-preapproval.v2":
require_rfc3339(item["expires_at"], f"approvals[{idx}].expires_at")
max_uses = item.get("max_uses", 10)
uses = item.get("uses", 0)
if isinstance(max_uses, bool) or not isinstance(max_uses, int) or max_uses < 1:
raise SystemExit(f"approvals[{idx}].max_uses must be an integer >= 1")
if isinstance(uses, bool) or not isinstance(uses, int) or uses < 0:
raise SystemExit(f"approvals[{idx}].uses must be an integer >= 0")
print("OK")
PY
}
cmd_apply_secret_allow() {
local project_root="${1:-}"
local state="${2:-}"
if [ -z "${project_root}" ] || [ ! -d "${project_root}" ]; then
echo "apply-secret-allow: project root not found: ${project_root:-<missing>}" >&2
exit 1
fi
if [ -z "${state}" ]; then
state="${project_root}/.claude/state/plan-preapprovals.json"
fi
cmd_validate "${state}" >/dev/null
python3 - "${project_root}" "${state}" <<'PY'
from datetime import datetime, timezone
import json
import os
import sys
project_root, state_path = sys.argv[1], sys.argv[2]
config_path = os.path.join(project_root, ".claude-code-harness.config.json")
with open(state_path, encoding="utf-8") as f:
state = json.load(f)
active_task_path = os.path.join(project_root, ".claude", "state", "active-task.json")
try:
with open(active_task_path, encoding="utf-8") as f:
active_scope = json.load(f)
except FileNotFoundError:
active_scope = {
"phase": os.environ.get("HARNESS_ACTIVE_PHASE", "").strip(),
"task": os.environ.get("HARNESS_ACTIVE_TASK", "").strip(),
}
except (OSError, json.JSONDecodeError) as exc:
raise SystemExit(f"apply-secret-allow: active task is unreadable: {exc}") from exc
if (
not isinstance(active_scope, dict)
or set(active_scope) != {"phase", "task"}
or not isinstance(active_scope.get("phase"), str)
or not isinstance(active_scope.get("task"), str)
or not active_scope["phase"].strip()
or not active_scope["task"].strip()
):
raise SystemExit("apply-secret-allow: active task scope is unavailable")
active_scope = {
"phase": active_scope["phase"].strip(),
"task": active_scope["task"].strip(),
}
approved_paths = []
for item in state.get("approvals", []):
if item.get("decision") != "approved":
continue
if item.get("scope") != active_scope:
continue
if "secret-read" not in item.get("operations", []):
continue
if state.get("schema_version") == "plan-preapproval.v2":
expires_at = datetime.fromisoformat(item["expires_at"].replace("Z", "+00:00"))
if expires_at <= datetime.now(timezone.utc):
continue
if item.get("uses", 0) >= item.get("max_uses", 10):
continue
for path in item.get("paths", []):
path = str(path).strip()
if path and path not in ("*", "**", "/"):
approved_paths.append(path)
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
config = json.load(f)
else:
config = {}
runtimefloor = config.setdefault("runtimefloor", {})
current = runtimefloor.get("secretAllow", [])
if not isinstance(current, list):
current = []
merged = []
for path in list(current) + approved_paths:
if isinstance(path, str) and path and path not in merged:
merged.append(path)
runtimefloor["secretAllow"] = merged
tmp_path = config_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp_path, config_path)
print(config_path)
PY
}
case "${1:-}" in
validate)
shift
cmd_validate "$@"
;;
apply-secret-allow)
shift
cmd_apply_secret_allow "$@"
;;
-h|--help|help|"")
usage
;;
*)
echo "unknown command: $1" >&2
usage >&2
exit 1
;;
esac