Orchestrata Ultimate › Plans › installer-config-manifest-plan

Installer hardening — a declared manifest replaces every literal in deploy/install-claude-config.sh

Source: plans/installer-config-manifest-plan.md at 8a2cb42ad · last changed 2026-10-09 · first committed 2026-10-09 · 246 lines.

Prose pending.

Plan text

Installer hardening — a declared manifest replaces every literal in deploy/install-claude-config.sh

Status: Proposed 2026-10-09 (user ruling: "fix the installer so the 5 issues never occur again, use configuration not hardcoding"). Amends SPEC-108 FR-001 / ERR-004 and SPEC-020. Scope: deploy/install-claude-config.sh, claude/scripts/{patch_settings,register_hooks}.py, the new claude/deploy.manifest.json + generators, their tests, one ADR.

0. The five measured defects (review of 2026-10-09, all reproduced live)

# Defect Measured evidence Root cause in the installer
1 Project /tmp/** deny rules are inert Write/Read on /tmp/claude-settings-deny-probe.txt succeeded with Read(/tmp/**), Write(/tmp/**) in .claude/settings.json Rule strings are hand-typed. Claude Code path prefixes: path=cwd-relative, /path=project-relative, //path=filesystem, ~/=home. Nothing lints or probes a rule.
2 Two stale ancestor CLAUDE.md files load into every session /home/mohamed/CLAUDE.md (38 KB, Jun 2026, a different project) and /home/mohamed/workspace/CLAUDE.md (Feb 2026, names a document-reader agent that does not exist, Python 3.11 floor) are injected as "project instructions" The installer manages only $PREFIX; the instruction-load chain (every ancestor CLAUDE.md of a project root) is never inspected. Hooks cite ~/.claude/CLAUDE.md, which does not exist.
3 The delete guard is cosmetic rm -f blocked (Bash-Delete-Blocked); python3 -c 'os.remove(...)' passed, pre-approved by ~/.claude/settings.local.json Bash(python3 -c ' *) settings.local.json is outside the installer's view. No layer lints allow rules that grant a generic interpreter, which defeats every text-matching Bash guard.
4 autoMode.environment calls the prod subnet routine, has 7 duplicated keys and 1 malformed line 10.53.1.10 / 10.53.1.x listed as "key internal services"; env/prod.env says 10.53.1.0/24 is prod; "sensitive targets" matches only the word prod The block is free text written by claude auto-mode setup, appended on re-run, owned by no file in the repo. The subnet facts already live in env/*.env and are not consulted.
5 Declared defaultMode: auto and nine incus/docker allows never deployed ~/.claude/settings.hooks.json declares them; ~/.claude/settings.json has defaultMode: default and none of the allows; register_hooks.py --check passes The declaration file mixes hooks and permissions but register_hooks.py diffs hooks only (_hooks_of, missing, extra). patch_settings.py applies interpreter retargeting and owned hooks only.

Cross-cutting cause: the installer hardcodes what it manages. SUBTREES=(hooks tools agents skills commands workflow), five cp lines, the dependency fallback jinja2 neo4j pydantic redis, the drift-check regex at line ~488 (which once lost statusline-command.sh), the plugin id, the smoke-test hook, and the backup retention of 3 are all literals in bash. Every past omission in this file (statusline-command.sh, test_*.py exclusion, the VERSION-before-verify marker) was a literal that drifted from intent. The fix is one declared manifest the installer reads, generators that render settings from it, and a verifier that checks every section of it, not only hooks.

1. Requirements

2. Design

One manifest, three generators, one verifier. claude/deploy.manifest.json (schema below) is the only place the installer learns what it manages. Three generators under claude/scripts/ render the parts of settings.json the deployment owns: gen_permissions.py (structured entries → rule strings), gen_automode.py (env/*.env + declared facts → autoMode), and the existing gen_plugin.py (unchanged input, now reads subtrees from the manifest). patch_settings.py becomes the single writer and applies every owned section idempotently (replace-by-owner-key, never append). register_hooks.py becomes verify_settings.py: a generic declared-vs-live diff over every section, plus the three new audits (permission lint, instruction chain, hook citations). The bash installer shrinks to: read manifest, stage, copy what the manifest lists, run the writer, run the verifier.

Why generate rule strings instead of lint them: a linter can only warn that /tmp/** is "probably" wrong, because / is a legal project-relative prefix. A structured entry {"tool":"Write","scope":"filesystem","glob":"/tmp/**"} has one correct rendering (Write(//tmp/**)), so the class of defect cannot be typed. The lint still runs on layers the installer does not generate (settings.local.json, project .claude/settings.json) because those are hand-edited by design.

Twin check: claude/scripts/ IS in the AST snapshot (ORCHESTRATA_AST_ROOTS includes claude; symbols --pattern ensure_owned_hooks resolves to claude/scripts/patch_settings.py:109). The 2026-10-09 review's "0 symbols" came from searching a MODULE name through symbols, which matches symbol names; source claude.scripts.patch_settings.<fn> is the correct query. No source-root change is needed. The graph side is a separate gap (see plans/ live-sync roots work, 2026-10-09): scripts/ast_live_sync.py hardcodes DEFAULT_SOURCE_DIR = "src/orchestrata_ultimate", so the code-intelligence graph never received scripts/, claude/ or tests/ modules.

2.1 Manifest schema — claude/deploy.manifest.json

{
  "schema": 1,
  "prefix_default": "~/.claude",
  "interpreter": {"env": "FT_PYTHON", "default": "~/.local/bin/python3.14t"},
  "subtrees": ["hooks", "tools", "agents", "skills", "commands", "workflow"],
  "files": [".envrc", "statusline-command.sh", "settings.hooks.json",
            "scripts/patch_settings.py", "scripts/verify_settings.py",
            "scripts/write_provider_restriction.py", "scripts/gen_permissions.py",
            "scripts/gen_automode.py", "CLAUDE.md"],
  "deps_fallback": ["jinja2", "neo4j", "pydantic", "redis"],
  "backup_retention": 3,
  "plugin": {"id": "orchestrata@orchestrata", "marketplace": "orchestrata", "source_tree": "plugins/orchestrata"},
  "smoke_hook": "hooks/platform_conventions_prompt.py",
  "settings": {
    "declaration": "settings.hooks.json",
    "owned_keys": ["hooks", "permissions.defaultMode", "permissions.allow", "env", "statusLine", "autoMode"]
  },
  "permissions": {
    "entries": [
      {"tool": "Bash", "scope": "command", "pattern": "incus list:*"},
      {"tool": "Read", "scope": "filesystem", "glob": "/tmp/**", "effect": "deny",
       "except": ["/tmp/claude-*/**"]}
    ],
    "forbidden_grants": {
      "description": "allow rules that hand out a generic interpreter defeat every Bash guard",
      "patterns": ["Bash(python* -c*", "Bash(python*", "Bash(sh -c*", "Bash(bash -c*",
                   "Bash(perl -e*", "Bash(node -e*", "Bash(eval*", "Bash(uv run python*"],
      "layers": ["settings.json", "settings.local.json", "<project>/.claude/settings.json",
                 "<project>/.claude/settings.local.json"],
      "on_violation": "fail"
    }
  },
  "automode": {
    "facts": "claude/automode.facts.json",
    "env_profiles_glob": "env/*.env",
    "sensitive_profiles": ["prod"],
    "routine_profiles": ["test", "dev"]
  },
  "instruction_chain": {
    "project_roots_from": "projects_dir",
    "allowed_ancestors": ["<prefix>/CLAUDE.md", "<project>/CLAUDE.md"],
    "on_unlisted": "fail"
  }
}

<prefix>/<project> are the two substitution tokens; no absolute path appears in the manifest. A JSON Schema (claude/deploy.manifest.schema.json) validates it at install time and in tests.

2.2 Decision entries (forks surfaced before coding)

One ADR: ADR-0062 "Deployed configuration is generated from a declared manifest; the installer carries no managed-content literal" — Status Proposed, one-way-door because every later addition to ~/.claude has to go through the manifest or fail the drift check.

3. Implementation units

Unit What changes Closes Tests
claude/deploy.manifest.json + deploy.manifest.schema.json new; every literal from §0 moves here all tests/deploy/test_manifest.py: schema-valid; no absolute paths; every files/subtrees entry exists at HEAD; register-time check that install-claude-config.sh contains none of the literals (grep the script for jinja2, orchestrata@orchestrata, hooks tools agents, platform_conventions_prompt → 0 hits)
claude/scripts/manifest.py loader + token substitution + get <dotted.key> CLI used by bash all round-trip, token substitution, unknown-key exit 2
claude/scripts/gen_permissions.py entries → rule strings; filesystem → //, home → ~/, project → /, cwd → bare; except renders as sibling allow rules 1 golden rendering per scope; a filesystem entry never renders a single leading /; --check diffs rendered vs live
claude/scripts/gen_automode.py reads env/*.env for subnets/hosts per profile and automode.facts.json; renders keyed lines; --import ingests an existing block once 4 no duplicate keys; no non-**/### lines; every host and CIDR from sensitive_profiles appears in a "Sensitive remote targets" line and in no "routine"/"key internal services" line; idempotent on re-run
claude/scripts/patch_settings.py single writer: replace each owned_keys section by owner key; keep machine-local extras under non-owned keys; interpreter retarget stays 4, 5 idempotent twice; declared defaultMode lands; declared allows land; unowned keys survive byte-for-byte
claude/scripts/verify_settings.py (replaces register_hooks.py) generic declared-vs-live diff over every owned section; permission lint across all layers; instruction-chain audit; hook-citation audit (~/.claude/<path> and $HOME/.claude/<path> literals in deployed hooks/ resolve) 1, 2, 3, 5 each audit has a failing fixture and a passing fixture; --check is read-only (reuses test_verify_mode_does_not_mutate_the_thing_it_judges pattern)
deploy/install-claude-config.sh reads manifest for subtrees/files/deps/retention/plugin/smoke hook; drift-check file list built from manifest, not a regex; calls generators then writer then verifier all existing tests/deploy/test_deploy_verification.py stays green; new: the script has no managed-content literal; drift list equals manifest list
claude/CLAUDE.md new managed file: platform conventions + the four boundaries install_secret_guard.py cites 2 deployed, cited paths resolve
plugins/orchestrata/project-hooks/_dispatch.sh log STANDDOWN instead of RUN when the repo's own registrations are live (audit honesty) audit log line for an in-repo call reads STANDDOWN
plugins/orchestrata/hooks/session_resume_context.py memory path from session project (D6) (review minor) worktree-mapped slug resolves
scripts/serialize_codebase_ast.py source roots add claude/scripts (toolkit gap) symbols --pattern patch_settings returns the module

4. Wiring plan

5. Wiring proof

6. Traceability gate

7. Quality gates

8. Order of work

  1. ADR-0062 (Proposed) + SPEC-108/020 amendments — half a day.
  2. Manifest + schema + loader + the literal-free installer (no generators yet; behaviour identical) — the existing verification suite proves nothing regressed.
  3. verify_settings.py generic section diff + declared defaultMode/allows land (defect 5).
  4. gen_permissions.py + project .claude/settings.json migrated to entries (defect 1).
  5. Permission lint across layers (defect 3).
  6. gen_automode.py with --import of the current block, then prod-subnet validation (defect 4).
  7. claude/CLAUDE.md managed + instruction-chain audit + hook-citation audit (defect 2).
  8. _dispatch.sh STANDDOWN logging, session_resume_context.py memory path, AST source root.
  9. Deploy via ship.sh; run --verify; record the two user-owned remediations it reports.

9. Definition of done