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
- Spec:
specs/108-environment-installer/spec.mdFR-001, ERR-004 (amend);specs/020-claude-code-harness/checklists/requirements.md(new REQ-020 rows below). - Platform conventions (
~/.claude/hooks/platform_conventions_prompt.py, RUL-021/027): a user-level deployment obeys the five deploy rules; the verifier refuses success unless the running thing is the deployed thing. This plan extends "running thing" from files to the effective configuration Claude loads. - New REQ ids (to add to SPEC-020 checklist; numbering continues after the last row):
- REQ-020-3xx-A manifest: every path, file list, id and policy the installer uses is read from
claude/deploy.manifest.json; the bash script contains no managed-content literal. - REQ-020-3xx-B permissions are generated from structured entries, never typed as rule strings.
- REQ-020-3xx-C the verifier lints every permission layer against a declared forbidden-grant list.
- REQ-020-3xx-D
autoMode.environmentis rendered fromenv/*.env+ a declared facts file, replaced wholesale, and validated against the prod profile. - REQ-020-3xx-E every declared section (hooks, permissions, defaultMode, env, statusLine) is diffed declared-vs-live; a declared-but-absent entry fails the deploy.
- REQ-020-3xx-F the instruction chain of every registered project root is audited against the
manifest's allowlist; an unlisted ancestor
CLAUDE.mdfails verification. - REQ-020-3xx-G every
~/.claude/<path>literal a deployed hook cites resolves to a deployed file.
- REQ-020-3xx-A manifest: every path, file list, id and policy the installer uses is read from
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)
- D1
claude/settings.jsonin the repo (legacy:fastMode,enforcement_hook, hardcodedpython3,allow: ["Bash","Incus"]) is neither deployed nor checked. Delete it. The declaration issettings.hooks.json, renamedsettings.declared.jsonbecause it now holds every owned section. (Alternative rejected: make it the declaration. Its content is stale on every line and a second declaration file is the defect class this plan removes.) - D2 Deploy a user-level
CLAUDE.mdfromclaude/CLAUDE.mdas a managed file, so the three hooks that cite it (platform_conventions_prompt.py,install_secret_guard.py,session_resume_context.py) are true. (Alternative rejected: strip the citations. The platform-conventions text is meant to live there per its own hook.) - D3 Ancestor
CLAUDE.mdoutside the prefix and the project root fail verification; the installer never deletes them (they belong to other repos). The remedy is printed. (Alternative rejected: auto-rename toCLAUDE.md.disabled. Mutating another repo from this installer is a one-way door nobody ruled on.) - D4 Forbidden grants in
settings.local.jsonfail verification rather than being removed. Same reasoning as D3: the file is the user's by design; the verifier names the rule and the bypass it enables. - D5
autoModeis owned: the deploy replaces the block.claude auto-mode setupoutput is consumed once intoclaude/automode.facts.jsonbygen_automode.py --import, then never read from the live file again. Re-running setup after a deploy is reported as drift. - D6
session_resume_context.pyresolves the memory index from the session's project (<projects_dir>/<slug>/memory/MEMORY.md, worktree-mapped the way Claude Code maps it), not a hardcoded-home-mohamed. Small, but it is the same defect class.
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
- Composition root:
deploy/install-claude-config.shis the only caller. Order after the change:manifest.py validate→ stage from pinned ref → copysubtrees+files→ shebang retarget →gen_permissions.py --write-decl+gen_automode.py --write-decl(both write into the staged declaration, never into the live file) →patch_settings.py(writer) → plugin sync →verify_settings.py --check(every audit) → drift check from manifest list → promote VERSION. - Consumer path:
deploy/ship.sh:567→ installer (unchanged entry).install.sh(SPEC-108) → installer--prefix(unchanged). - Lifecycle: generators and verifier run under
$PREFIX/.venv/bin/python(deployed venv), never the system interpreter;--verifymode runs the verifier only and mutates nothing. - Config: the manifest is versioned with the repo and deployed to
$PREFIX/deploy.manifest.jsonso--verifyon a machine without a checkout audits against the deployed declaration.env/*.envis read from the pinned ref's archive, not the working tree.
5. Wiring proof
- Construction: a fresh
--prefix "$tmp"deploy from HEAD produces asettings.jsonwhosepermissions.allow,defaultMode,autoModeandhooksequal the generators' output and whoseVERSIONrecords HEAD. - Reachability, one per defect, each a test that fails if its mechanism is removed:
- a manifest entry
{"tool":"Write","scope":"filesystem","glob":"/tmp/**"}rendersWrite(//tmp/**); a hand-typedWrite(/tmp/**)in the declaration fails lint. - a fixture tree
<tmp>/CLAUDE.mdabove<tmp>/ws/repowithprojects_dirnamingrepofailsverify_settings.py --checkwith the file named; listing it inallowed_ancestorspasses. - a fixture
settings.local.jsonwithBash(python3 -c ' *)fails the lint and the message names the bypass; removing the rule passes. - with
env/prod.envdeclaring10.53.1.0/24, a facts file that lists10.53.1.10as a routine service failsgen_automode.py --check; the rendered block has unique keys. - declaring
defaultMode: autoand an allow in the declaration, then deploying, yields both in the live file; deleting either from the live file fails--verify.
- a manifest entry
6. Traceability gate
- Each generator/verifier function carries
@tracesfor its REQ-020-3xx row; shell units are traced through the SPEC-020 checklist rows the waydecision_adr_trigger.shis. python scripts/serialize_codebase_ast.py check-tracesgreen; RTM regenerated in the commit.- ADR-0062 minted via the
decisionskill in all three homes (docs/wiki/graph) before the installer edit lands; thedecisionskill's design-waiver rule is not invoked.
7. Quality gates
- Coverage ≥90% on
manifest.py,gen_permissions.py,gen_automode.py,patch_settings.py,verify_settings.py(subprocess-visible). - ruff, pyright ratchet,
test-review.pyclean; no mocks (fixtures are real temp trees and real JSON files); no monkeypatch (interpreter and prefix enter via--prefix/manifestenvkeys). deploy/install-claude-config.sh --verifygreen on this workstation after the deploy, with defects 2 and 3 expected to FAIL until the two$HOMEancestor files and the local allow rule are resolved by the user (D3, D4): the verifier reports them; the plan does not remove them.- Ship through
deploy/ship.shin the same motion as the merge (deployment is not a separate decision).
8. Order of work
- ADR-0062 (Proposed) + SPEC-108/020 amendments — half a day.
- Manifest + schema + loader + the literal-free installer (no generators yet; behaviour identical) — the existing verification suite proves nothing regressed.
verify_settings.pygeneric section diff + declareddefaultMode/allows land (defect 5).gen_permissions.py+ project.claude/settings.jsonmigrated to entries (defect 1).- Permission lint across layers (defect 3).
gen_automode.pywith--importof the current block, then prod-subnet validation (defect 4).claude/CLAUDE.mdmanaged + instruction-chain audit + hook-citation audit (defect 2)._dispatch.shSTANDDOWN logging,session_resume_context.pymemory path, AST source root.- Deploy via
ship.sh; run--verify; record the two user-owned remediations it reports.
9. Definition of done
- [ ]
grep -cE 'jinja2|orchestrata@orchestrata|hooks tools agents|platform_conventions_prompt|statusline-command' deploy/install-claude-config.sh→ 0 - [ ]
~/.claude/settings.jsonpermissions.defaultMode,permissions.allow,autoModeequal generator output; re-running the installer changes 0 bytes - [ ]
Write(//tmp/...)probe is refused on this workstation (or the entry is deliberately absent); no/-prefixed filesystem-intent rule exists in any layer - [ ]
--verifynames/home/mohamed/CLAUDE.md,/home/mohamed/workspace/CLAUDE.mdandBash(python3 -c ' *)as findings until resolved - [ ]
autoMode.environmenthas unique keys, no fragment lines, and lists10.53.1.0/24only under sensitive targets - [ ]
scripts/serialize_codebase_ast.py symbols --pattern verify_settingsreturns the module - [ ] ADR-0062 Accepted on Mohamed's ruling; RTM rows for REQ-020-3xx-A..G implemented + tested