Migrate config.yaml from v8 to v9
What the config_version 8 to 9 migration moves into config.yaml, which commands run it, before and after examples for every moved key, how conflicts are recorded in migration-v9.json, and how to go back to DefenseClaw 0.8.x.
config_version: 9 makes config.yaml the only place an administrator's
decisions live (see Configuration: one source of truth).
DefenseClaw 0.8.x kept some of those decisions elsewhere: admission actions and
thresholds in policies/rego/data.json, operator block and allow entries in
audit.db, scanner switches in loose keys. The migration copies each of them
into config.yaml, keeps the old files, and records every value it moved and
every disagreement it settled.
You rarely run it by hand. This page shows what it does so you can check the result.
Who needs it
| Install | What happens |
|---|---|
Per-user install of 0.8.5 or later (config_version: 8) | The upgrade runs the migration: defenseclaw upgrade, or the install command on 0.8.7 and older and on Windows (Upgrading from 0.x to 1.0). Until it has run, the gateway reads your v8 file as if it were migrated, in memory only, so nothing you configured stops applying. CLI reads such as defenseclaw config get --effective still show what the v8 file itself says. |
Per-user install of 0.8.4 or older (config_version: 7) | The same upgrade first converts the file to version 8 (the one-time 0.x import), then runs this migration. |
Managed standalone host (deployment_mode: managed_enterprise, enterprise.profile: standalone) | The lifecycle (ensure or upgrade) migrates a version 8 admin config in place and keeps config.yaml.v8.bak. Operator rows in audit.db are not moved: the admin config is the policy. See Lifecycle. |
| Secure Client profile | No change. It keeps its existing path: the gateway does not migrate its config in memory. |
A fresh defenseclaw init | init writes config_version: 9. There is nothing to migrate. |
An unmodified 0.8.x install moves one value (scanners.skill_scanner.policy: quiet) and no conflicts. The rest of this page covers customised installs.
What runs it
| Command | What it does |
|---|---|
defenseclaw upgrade | The installer checks, before it changes anything, that your configuration can be migrated, then runs defenseclaw migrate after the swap. If the migration fails, the installer puts the previous install back. |
defenseclaw migrate | Brings configuration and data to this release's schema, including the v9 step. --check lists the pending steps without changing anything. |
defenseclaw config migrate [--dry-run] [--ack] [--json] | Runs only the v9 step. --dry-run writes nothing. --ack marks migration-v9.json as read. --json prints the whole record. |
defenseclaw-gateway config migrate [--dry-run] [--ack] [--json] | The same implementation, which defenseclaw config migrate calls. Its output also lists the removed keys and the audit entries it moved. |
| The gateway, at start and on a reload | A config_version: 8 file is migrated in memory, read-only. The file on disk is not touched. If the in-memory migration fails, the gateway refuses the file: at start it exits with an error that tells you to run defenseclaw migrate, and on a reload the previous configuration keeps running. |
A migration whose result would not validate stops with an error and writes
nothing. So does one that finds config.yaml changed while it ran: run it
again. A file that is already at version 9 prints
config.yaml is already config_version 9; nothing to migrate.
defenseclaw migrate --check • 1 migration step(s) pending (config_version 8 -> 9):
→ config_version 8 → 9
Nothing was changed. 'defenseclaw migrate' (or the upgrade) applies them.Try it first
--dry-run shows what would move. The example below is a customised v8
install: data.json with stricter actions and thresholds, a custom rule pack,
the old scanner switches, update_check: false, and four operator block and
allow entries in audit.db. Paths are shortened.
defenseclaw config migrate --dry-runWould move 31 values into config.yaml; 2 conflicts.
guardrail.connectors.codex.rule_pack_dir -> guardrail.connectors.codex.rule_pack
rule_pack_dir -> guardrail.custom_packs.acme-strict
guardrail.connectors.cursor.rule_pack_dir -> guardrail.connectors.cursor.rule_pack
data.json:actions.CRITICAL -> admission.skill.actions.critical
data.json:actions.HIGH -> admission.skill.actions.high
data.json:actions.MEDIUM -> admission.skill.actions.medium
data.json:actions.LOW -> admission.skill.actions.low
data.json:actions.INFO -> admission.skill.actions.info
data.json:actions.CRITICAL -> admission.mcp.actions.critical
data.json:actions.HIGH -> admission.mcp.actions.high
data.json:actions.MEDIUM -> admission.mcp.actions.medium
data.json:actions.LOW -> admission.mcp.actions.low
data.json:actions.INFO -> admission.mcp.actions.info
data.json:first_party_allow_list -> admission.skill.first_party_allow_list
data.json:guardrail.block_threshold -> guardrail.block_at
data.json:guardrail.alert_threshold -> guardrail.alert_at
data.json:guardrail.cisco_trust_level -> guardrail.cisco_trust_level
scanners.skill_scanner.policy -> scanners.skill_scanner.policy_file
scanners.skill_scanner.virustotal_api_key -> .env:VIRUSTOTAL_API_KEY
scanners.skill_scanner.use_virustotal -> scanners.skill_scanner.analyzers.virustotal.enabled
scanners.skill_scanner.virustotal_api_key_env -> scanners.skill_scanner.analyzers.virustotal.api_key_env
scanners.skill_scanner.use_aidefense -> scanners.skill_scanner.analyzers.aidefense.enabled
scanners.mcp_scanner.analyzers -> scanners.mcp_scanner.analyzers
scanners.mcp_scanner.llm -> scanners.mcp_scanner.judge_source
~/.defenseclaw/signature-packs/*.json -> ai_discovery.signature_packs
~/.defenseclaw/signature-packs/*.json -> ai_discovery.signature_pack_digests
update_check -> update.check
actions:r1 -> asset_policy.skill.denied
actions:r2 -> asset_policy.skill.allowed
actions:r3 -> asset_policy.mcp.denied
actions:r5 -> asset_policy.tool.denied
conflict admission.defaults.allow_list_bypass_scan: kept data.json:true, dropped watch.allow_list_bypass_scan:false
conflict admission.skill.actions.medium: kept data.json:install=none,file=none,runtime=enable, dropped skill_actions.medium:install=block,file=quarantine,runtime=disableEach line reads v8 source -> v9 key. The sections below show the files on both
sides of the arrow. Add --json for the full record, or run the gateway form
for the list of removed keys:
defenseclaw-gateway config migrate --dry-runRunning the same command without --dry-run migrates for real:
defenseclaw config migrateWhen the upgrade runs the migration for you, it prints one summary line:
→ config_version 8 → 9
✓ moved 31 policy values into config.yaml (config_version 9); 2 conflicts recorded in ~/.defenseclaw/migration-v9.json
✓ Migrated to config_version 9 (1 step(s)).What moves where
The migration writes only values that differ from the built-in policy. A v8 setting that already matches the built-in default produces no v9 key, so the migrated file stays short.
Admission and thresholds from data.json
In 0.8.x, policies/rego/data.json decided what happens to a skill, MCP server
or plugin by scan severity, the first-party allow list, and the LLM proxy's
guardrail thresholds. In v9 that is admission: and the guardrail
thresholds, and data.json is no longer read.
The migration reads <policy_dir>/rego/data.json. A policy_dir written as
~/team-policies becomes the absolute folder (/home/you/team-policies),
because 1.0 reads policy_dir as written. 0.8.x did not expand the ~ either:
policy activate wrote its data.json to a folder literally named ~ in your
home directory (/home/you/~/team-policies/rego/data.json), and the 0.8.x
gateway enforced that file. When that file is the newer of the two, the
migration reads it, so the policy you activated (strict, for example, with
block level MEDIUM) carries over, and migration-v9.json notes which file it
read.
{
"actions": {
"CRITICAL": { "runtime": "block", "file": "quarantine", "install": "block" },
"HIGH": { "runtime": "block", "file": "none", "install": "block" },
"MEDIUM": { "runtime": "allow", "file": "none", "install": "none" }
},
"first_party_allow_list": [
{ "target_type": "skill", "target_name": "acme-helper",
"reason": "first-party Acme skill",
"source_path_contains": [".claude/skills/acme-helper"] }
],
"guardrail": {
"block_threshold": 3,
"alert_threshold": 1,
"cisco_trust_level": "advisory"
}
}guardrail:
block_at: HIGH
alert_at: LOW
cisco_trust_level: advisory
admission:
skill:
actions:
critical: quarantine
high: block
medium: warn
low: warn
info: warn
first_party_allow_list:
- name: codeguard
source_path_contains: [.openclaw/workspace/skills/codeguard, .openclaw/skills/codeguard, .zeptoclaw/skills/codeguard, .claude/skills/codeguard]
reason: first-party DefenseClaw skill
- name: acme-helper
source_path_contains: [.claude/skills/acme-helper]
reason: first-party Acme skill
mcp:
actions:
critical: quarantine
high: block
medium: quarantine
low:
install: none
file: none
runtime: disable
info: warn- An action becomes the shorthand
block,quarantineorwarnwhen it matches one; any other combination is written as the exactinstall,fileandruntimetriple, as the MCPlowentry shows. The MCP entries appear because 0.8.x shipped stricter MCP overrides inscanner_overrides; the migration keeps what enforcement did. See Admission for the shorthands. - When any severity of a type differs from the built-in, all five severities of that type are written.
scan_on_installandallow_list_bypass_scanare written toadmission.defaultsonly whendata.jsonturned them off (forallow_list_bypass_scan, also when it left the key out, because 0.8.x bypassed the scan only for an explicittrue).first_party_allow_listis written for a type only when it differs from the built-in list and from the list DefenseClaw 0.x shipped. The shipped list was never your choice, so the 1.0 built-in list replaces it, as on a fresh install, andpolicy liststill marks the policy you activated.- A
first_party_allow_listentry withoutsource_path_containsmatched any path in v8, and skipped the scan only whereallow_list_bypass_scanwas on. A v9 first-party entry needs a path, so where the bypass applies to its type the entry becomes a name-onlyasset_policy.<type>.allowedentry. Where it does not, v8 scanned the asset anyway: the entry is dropped and a note is recorded. - A value
config.yamlalready sets underadmission:wins. Before the migration has run,defenseclaw config set,policy activateand the TUI write such values into the version 8 file, and the gateway applies them when it reads that file in memory. Wheredata.jsonheld a different value, a conflict is recorded that keeps the config value. cisco_trust_levelis written when it is notfull.- Keys that v9 does not read (
config.policy_name,config.max_enforcement_delay_seconds,severity_ranking,audit,guardrail.hilt,guardrail.patterns,guardrail.severity_mappings,guardrail.severity_rank) are dropped and listed underremovedin the record.
Thresholds. In v8 the severity that blocks came from different places on
different paths: config.yaml for hook tool calls, the rule pack's posture for
hook prompts, and data.json for the LLM proxy. In v9 guardrail.block_at and
guardrail.alert_at are the only source and apply to hook prompts, hook tool
calls and the proxy alike (Thresholds). The
migration settles the old values like this:
| In v8 | In v9 |
|---|---|
data.json is stricter than the rule pack's default | Written to guardrail.block_at or alert_at. |
data.json holds the shipped value | Not written; the proxy now follows the rule pack's default. A note is recorded. |
data.json is looser than the rule pack's default | Not written. The pack default stays and a conflict is recorded. |
config.yaml already sets block_at or alert_at | The config value wins. A conflict is recorded when data.json was stricter, because that stricter level used to apply to the proxy. |
When config.yaml sets any block_at or alert_at, the record also carries
the note guardrail block_at/alert_at now apply to hook prompts, hook tool calls and the LLM proxy alike. Read it: a threshold that used to cover only some
paths now covers all of them.
skill_actions, mcp_actions and plugin_actions
These keys looked like the place to tune admission, but enforcement read
data.json, so they only filled severities data.json did not define. They
are removed. A value you had customised away from what data.json enforced is
recorded as a conflict, and the data.json value is kept because that is what
was enforced. A managed host has no data.json: there the built-in defaults
were what was enforced, so the conflict reads kept defaults:... and the
reason says there was no data.json.
skill_actions:
medium:
file: quarantine
runtime: disable
install: block{
"to": "admission.skill.actions.medium",
"kept": "data.json:install=none,file=none,runtime=enable",
"lost": "skill_actions.medium:install=block,file=quarantine,runtime=disable",
"reason": "enforcement read data.json; skill_actions only filled unknown severities"
}If you wanted the stricter value, set it now:
defenseclaw config set admission.skill.actions.medium quarantinewatch.allow_list_bypass_scan
watch.allow_list_bypass_scan had no reader either. It is removed, and when it
disagreed with data.json the conflict is recorded:
watch:
allow_list_bypass_scan: false{
"to": "admission.defaults.allow_list_bypass_scan",
"kept": "data.json:true",
"lost": "watch.allow_list_bypass_scan:false",
"reason": "enforcement read data.json; watch.allow_list_bypass_scan had no reader"
}To scan allow-listed assets anyway, set the v9 key:
defenseclaw config set admission.defaults.allow_list_bypass_scan falseRule packs: rule_pack_dir
rule_pack_dir is removed at every scope (global, guardrail.connectors,
guardrail.profiles and a profile's connectors). It becomes rule_pack and,
where needed, custom_packs and rules.protections
(Rules).
guardrail:
connectors:
codex:
rule_pack_dir: /home/<you>/.defenseclaw/policies/guardrail/strict
cursor:
rule_pack_dir: /etc/acme/defenseclaw/packs/acme-strictguardrail:
connectors:
codex:
rule_pack: strict
cursor:
rule_pack: acme-strict
custom_packs:
acme-strict:
path: /etc/acme/defenseclaw/packs/acme-strict
digest: sha256:47c16bf9651541dfe808ef1f6127b7a65f6f7982b99d1739e334633c88d2e9f5rule_pack_dir in v8 | rule_pack in v9 |
|---|---|
<policy_dir>/guardrail/default, strict or permissive (balanced counts as default) | The preset name. |
A protected-<scope>/<preset> folder composed from protections (only pre-release builds wrote these) | The preset name, plus the folder's protection list as rules.protections. The protected-* folders are not used in v9 and are composed in memory. |
| Any other folder | A new custom_packs entry named after the folder (accented letters folded, so équipe sécu is equipe-secu), with the pack's digest pinned. A name that is taken gets a -2, -3 suffix. |
In 1.0 a rule blocks a tool call only with an expression over the parsed
action; its pattern alone records a match on a tool call but never blocks it
(prompts and content still block on the pattern). The 0.8.x rules had no
expressions, so a custom pack copied from the 0.8.x default pack would enforce
nothing, and your own rules would stop blocking tool calls. When the migration
finds such a pack, it writes a 1.0 copy next to it, <folder>-1.0, and pins
that one instead:
-
a rule file copied from the 0.8.x default pack is rebuilt on the 1.0 one: the built-in rules become their 1.0 versions, and the ones your copy turned off or removed stay off;
-
your own rules, in any rule file and category, are kept. A rule whose
patternis a plain literal (such asacme-marker-7f3coracme\.internal) gets an expression and blocks what the 0.8.x pattern blocked, which was the literal anywhere in the text of the tool call: the gateway runs the pattern over that text, as 0.8.x did, and over the words it parsed from it. That covers a command word (echo acme-marker-7f3c,touch acme-marker-7f3c.txt,echo xacme-marker-7f3cy,python3 -c "print('acme-marker-7f3c')"), a quoted string or here-string, also inside a call DefenseClaw parses only in part, such as a .NET method call in PowerShell ([System.IO.File]::WriteAllText($path, 'acme-marker-7f3c')), a file path or network host (cat /tmp/acme-marker-7f3c/x,curl https://acme.internal/), and text a tool writes or sends, such as the content of a file write. This works the same on Linux, macOS and Windows and for every connector. A literal between\bword boundaries (\bacme-marker\b) still has to be a whole word, as in 0.8.x. The upgrade anddefenseclaw doctorname these rules; -
a built-in rule whose
patternyou changed is kept as a rule of yours under an ID of its own,CUSTOM-<ID>(CMD-RM-RFbecomesCUSTOM-CMD-RM-RF, orCUSTOM-CMD-RM-RF-2when a rule file of the pack already has that ID), with your pattern, title, severity and other fields, and gets an expression as above (or is named detection-only). The built-in ID keeps the shipped 1.0 rule: the gateway checks a match of a built-in ID against what the shipped rule means (aCMD-RM-RFmatch counts only for a recursive delete of a root folder), so your pattern would never block under it. Block messages and audit rows name the new ID; use it in searches and suppressions. A built-in rule you only retitled, turned off or gave another severity keeps its ID; -
rule files that share a category are merged into the first of them, in file-name order, and every rule is kept. 1.0 needs one file per category (0.8.x took several and enforced only the last one). Categories match regardless of case and surrounding spaces. A moved rule whose ID that file already has is dropped when it is the same rule, and otherwise gets the name of its file as a suffix (
ACME-1fromrules/acme-extra.yamlbecomesACME-1-acme-extra). The upgrade prints each merge:Upgrade output ✓ rule pack /home/<you>/.defenseclaw/policies/guardrail/acme-1.0: rules/acme-extra.yaml (category "command") merged into rules/commands.yaml; 3 rule(s) kept -
a rule file or folder that is a link is copied in its place with the files it points to: 0.8.x read rule files through links, and 1.0 refuses a link in a pack. A pack with links gets a 1.0 copy even when nothing else changes; edit the copy from then on. A link that does not resolve, loops, or points outside the pack stops the migration with an error that names it: replace the link with the file it points to, and run the upgrade again.
Any other rule of yours keeps only its pattern. The migration names each one, and the upgrade prints them on one line:
⚠ 2 custom rule(s) now detection-only for tool calls: ACME-SPACED, ACME-REGEX; add an expression, see policies/rulesThe upgrade also names, one per line, each rule that kept blocking (a
built-in you edited under its new ID) and each built-in you edited that is
now detection-only
(defenseclaw-gateway config migrate prints the same lines as rule lines):
⚠ 2 rule(s) of your custom pack changed by the 1.0 upgrade (see migration-v9.json):
- CUSTOM-CMD-RM-RF (your edited CMD-RM-RF; CMD-RM-RF is the shipped 1.0 rule): enforced as on 0.8.x (its severity decides block or alert) when the text of a tool call holds its literal (as a whole word when the pattern has \b): a command word, a quoted string, a file path, a network host or text a tool writes, such as file content
- ACME-MARKER: enforced as on 0.8.x (its severity decides block or alert) when the text of a tool call holds its literal (as a whole word when the pattern has \b): a command word, a quoted string, a file path, a network host or text a tool writes, such as file contentdefenseclaw doctor shows these lines in its Migrated custom rules row
until you mark the record as read with defenseclaw-gateway config migrate --ack.
defenseclaw doctor warns about them until you give each an expression
(CEL rules). A pack with nothing to rewrite is
pinned as it is, and its rules are still named. The original folder is kept
for a rollback to 0.8.x. The migration record (migration-v9.json) lists what
moved, under detection_only_rules the rules that no longer block a tool
call, under rule_file_merges the merged rule files, and under
renamed_rules and expressed_rules the rules named above.
If the 1.0 copy of a pack that loads as it is can not be made, the pack is
pinned as it is and its 0.8.x copies of built-in rules and its rules without
an expression only record tool-call matches. The upgrade prints a warning
that names the pack and the reason, migration-v9.json lists it under
rule_pack_rebase_failures, doctor shows a Rule pack rebase warning and
the gateway writes a HIGH upgrade audit event at each start until you run
defenseclaw-gateway config migrate --ack. Rebase the pack by hand on the 1.0
default pack and pin it with defenseclaw guardrail use-pack <folder>.
A custom pack that cannot be loaded stops the migration with an error that
names the scope, so a pack you rely on is never dropped silently. So does a
merge that cannot be done safely (the file would have more than 2,048 rules,
the files have different versions, or one sets more than version, category
and rules), and two files of one category in an administrator's pack on a
managed host, which the migration never rewrites. The error names the edit:
move the rules of one file into the other, delete it, and run the upgrade
again. Later edits
to the pack's files change its digest: validate the pack and update the digest
(see Rules).
guardrail:
rule_pack_dir: /home/<you>/.defenseclaw/policies/guardrail/protected-claudecode/strictguardrail:
rule_pack: strict
rules:
protections: [database-destruction-protection]Scanner keys
The skill scanner and MCP scanner no longer take a binary, and the
analyzers have their own blocks.
scanners:
skill_scanner:
binary: skill-scanner
use_llm: true
use_behavioral: true
policy: /etc/acme/defenseclaw/skill-policy.yaml
use_virustotal: true
virustotal_api_key: <inline key>
use_aidefense: true
mcp_scanner:
binary: mcp-scanner
analyzers: yara,api,llm
llm:
provider: openai
model: gpt-5.4-mini
api_key_env: ACME_MCP_JUDGE_KEYscanners:
skill_scanner:
use_llm: true
use_behavioral: true
policy: custom
policy_file:
path: /etc/acme/defenseclaw/skill-policy.yaml
digest: sha256:deb27ac5d366dca4b5a89ca0206ea14013d876bafa00e44c270637fd7c3a829a
analyzers:
virustotal:
enabled: true
api_key_env: VIRUSTOTAL_API_KEY
aidefense:
enabled: true
mcp_scanner:
analyzers: [yara, api, llm]
llm:
provider: openai
model: gpt-5.4-mini
api_key_env: ACME_MCP_JUDGE_KEY
judge_source: override| v8 key | v9 |
|---|---|
scanners.skill_scanner.binary, scanners.mcp_scanner.binary | Removed. DefenseClaw runs the scanners it ships. |
scanners.skill_scanner.policy empty or missing | policy: quiet, the recommended preset. This is the one value an unmodified install moves. |
policy: strict, balanced, permissive, low-noise or quiet | Kept. |
policy: <path to a file> | policy: custom with policy_file: {path, digest}. A file that cannot be read becomes quiet and a conflict is recorded. |
use_virustotal | analyzers.virustotal.enabled (written only when it was true). |
virustotal_api_key (inline) | Written to <data_dir>/.env under the name in virustotal_api_key_env, or VIRUSTOTAL_API_KEY. The record shows <redacted>, and config.yaml names only the variable. |
virustotal_api_key_env | analyzers.virustotal.api_key_env. |
use_aidefense | analyzers.aidefense.enabled (written only when it was true). |
mcp_scanner.analyzers: yara,api,llm (a comma list) | A YAML list. auto alone, or an empty value, becomes [] (automatic). auto inside a list becomes yara plus the others. Names the scanner does not have are dropped. |
A non-empty skill_scanner.llm or mcp_scanner.llm block | judge_source: override. With no block the scanner inherits the top-level llm:. |
The remaining scanner keys are in the configuration reference. The scanner pages explain the gate and the judge: Skill scanner, MCP scanner.
update_check
update_check: falseupdate:
check: falseOnly pre-release builds wrote update_check; a 0.8.x install does not have it.
update.channel (only stable) and update.source are new; see
Upgrade DefenseClaw.
Operator block and allow entries from audit.db
In 0.8.x, defenseclaw skill block, mcp block, plugin block and the
matching allow commands wrote rows into the actions table of audit.db. In
v9 those commands write asset_policy.<type>.denied and allowed in
config.yaml, and the gateway hot-applies the change. The migration copies the
rows you wrote:
| Row | Type | Name | State | Reason |
|---|---|---|---|---|
r1 | skill | acme-notes (connector claudecode) | install: block | vendor not reviewed |
r2 | skill | acme-lint | install: allow | reviewed by security |
r3 | mcp | acme-wiki | install: block | sends data to an unknown host |
r4 | skill | scratch-tool | install: block, file: quarantine | auto-block: HIGH findings |
r5 | tool | @claudecode/WebFetch | install: block | no web fetch in CI |
asset_policy:
skill:
denied:
- name: acme-notes
connector: claudecode
reason: vendor not reviewed
allowed:
- name: acme-lint
reason: reviewed by security
source_path_contains: [/home/alice/.claude/skills/acme-lint]
mcp:
denied:
- name: acme-wiki
reason: sends data to an unknown host
tool:
denied:
- name: WebFetch
connector: claudecode
reason: no web fetch in CI- Row
r4stays inaudit.db. A block that a scan verdict wrote (a reason that startsauto-block,post-scan:,post-install scan:orscan:) is the enforcement journal, not an operator decision. Quarantine and runtime-disable state is unchanged. - A moved row loses its
installfield afterconfig.yamlis committed, and a row left with no state is deleted. If anything fails first, the row is still in the table: an entry is never lost. - An allow entry is pinned to the path it was recorded for
(
source_path_contains). A block entry matches by name, as in 0.8.x. - Tool names recorded as
@<connector>/<tool>become aconnectorand aname. - On a managed standalone host the rows are not moved. The record counts them
in
actions_rows_ignoredand adds the notelocal_enforcement_entries_ignored, because the admin config is the policy.
See Admission for asset_policy and how block and
allow entries are matched.
AI discovery signature packs
0.8.x loaded every *.json file in <data_dir>/signature-packs/ implicitly.
In v9 only configured packs load. The migration lists each installed pack and
pins its SHA-256 digest. Managed standalone validation requires a pin for every
existing pack before it applies the migrated config:
ai_discovery:
signature_packs:
- /home/alice/.defenseclaw/signature-packs/acme-agents.json
signature_pack_digests:
/home/alice/.defenseclaw/signature-packs/acme-agents.json: sha256:042a9931f9f1cd782229ec4eff49a4b40c946728bc3675b9184d457c4f1bf8a5The digest above is illustrative; the migration computes it from the pack's actual bytes. An existing pin is retained.
Pre-9 Rego modules
A 0.8.x install still holds the policies/rego/admission.rego and
guardrail.rego that its release seeded, and they read data.json. On a
per-user install the migration keeps the old module as
<name>.rego.migrated-v9 and replaces it with the shipped module. The upgrade
then moves an unmodified 0.8 sandbox.rego, which 1.0 no longer ships, to
~/.defenseclaw/backups/rego-<time>/ (see
Shipped policy files). On a
managed host the administrator owns policy_dir, so the migration only
records a note, and the gateway refuses the old module and uses the
config-driven policy.
The migration checks every module under policies/rego that the admission or
guardrail module reaches, with the same check the gateway runs: a module that
reads any data.* document outside data.defenseclaw is a pre-9 module. The
data.json values 0.8.x shipped (data.config, data.actions,
data.guardrail and the others above) move into config.yaml. A key of your
own in data.json, such as data.operator_policy, has no 1.0 home: the rules
that read it are no longer enforced. The migration lists each such module and
key under unenforced_rego_data in migration-v9.json, the upgrade and
config migrate print it as a warning, and doctor shows a Rego data warning
until you run defenseclaw-gateway config migrate --ack. Express those rules
in config.yaml (asset_policy, admission) or in a module that reads
input.
Custom providers
The migration moves entries from a legacy custom-providers.json into
llm_providers.custom and llm_providers.ollama_ports in config.yaml.
For an overlay inside the data home, it then renames the old file to
custom-providers.json.migrated-v9. An overlay outside the data home stays
in place so a 0.8.x rollback can still read it. Future provider changes use
config.yaml; any generated overlay is derived from that config.
If any legacy provider has request_overrides, which llm_providers cannot
hold, migration records a note and leaves the entire unmarked overlay as a
live input. The gateway continues to merge it even after changes to
llm_providers. Preserve and review that file until the overrides are no
longer needed. Managed hosts do not import local provider overlays into their
admin config. See
Custom providers.
Keys that no longer exist
| v8 key | Now |
|---|---|
policies/rego/data.json | Ignored. Renamed to data.json.migrated-v9 by the migration. |
skill_actions, mcp_actions, plugin_actions | admission |
watch.allow_list_bypass_scan | admission.defaults.allow_list_bypass_scan |
rule_pack_dir (every scope) | rule_pack, custom_packs, rules |
scanners.skill_scanner.binary, scanners.mcp_scanner.binary | Removed |
scanners.skill_scanner.use_virustotal, use_aidefense, virustotal_api_key, virustotal_api_key_env | scanners.skill_scanner.analyzers.* |
update_check | update.check |
Operator rows in the audit.db actions table | asset_policy |
A v9 file that still holds one of them is rejected by validation. Keys that
earlier releases already retired, such as privacy.disable_redaction (use the
redaction profiles under observability), are not part of this migration.
MCP servers that can no longer be scanned
0.8.x ran any command an MCP server entry named. The 1.0 scanner starts only
npx or uvx (on Windows also npx.cmd and uvx.exe) with a package
argument, or connects to a URL; it never starts a command path such as
/usr/bin/true, ./srv.sh, bin/srv or C:\tools\srv.exe, or another program.
After the migration, defenseclaw migrate lists every entry of your
connectors that the scanner refuses, in the summary and under
unscannable_mcp in migration-v9.json:
✓ MCP servers that can no longer be scanned (2): the scanner starts only npx or uvx with a package, or a URL
✓ u33a-mcp (claudecode): command '/usr/bin/true' still runs, without a scan (configured before the upgrade); a new or changed definition fails install admission and is blocked; fix: defenseclaw mcp set u33a-mcp --command npx --args <package> --connector claudecode (or --url <url>)
✓ u33a-mcp (codex): command '/usr/bin/true' still runs, without a scan (configured before the upgrade); a new or changed definition fails install admission and is blocked; fix: defenseclaw mcp set u33a-mcp --command npx --args <package> --connector codex (or --url <url>)- Such a server keeps running in every connector: at its first start the
gateway records its baseline without install admission, and
mcp listshows it asscan failedwith no action. It is never scanned. - If you change the entry, or add a server like it, install admission runs, the
scan cannot start and the server is blocked (only reported when
gateway.watcher.mcp.take_actionisfalse). - A server on a block list is not listed here: it stays blocked and the summary
names it under
asset_policy.mcp.denied. - The fix points the entry at
npxoruvxwith its package, or at the server's URL. When the command is a path tonpxoruvx, the fix keeps your arguments and uses the bare name. - Claude Code: 0.8.x wrote
mcp setentries into themcpServersblock of~/.claude/settings.json. 1.0 writes them to~/.claude.json($CLAUDE_CONFIG_DIR/.claude.jsonwhen that is set), the file Claude Code reads. DefenseClaw still reads the old block, after~/.claude.json, andmcp listnames an entry that is only there. The fix command with--connector claudecodewrites the entry to~/.claude.jsonand removes the copy fromsettings.json;defenseclaw mcp unset <name> --connector claudecoderemoves the name from both files.
Conflicts and migration-v9.json
Every run that writes also writes migration-v9.json next to config.yaml
(mode 0600):
{
"schema_version": 1,
"from_version": 8,
"to_version": 9,
"migrated_at": "2026-10-06T18:15:25Z",
"actor": "migration",
"source_sha256": "23f21dc46493dd0cefddd1389d1009c18894c84f69460a2d992fc1063448de73",
"result_sha256": "26dcbe4330146f67d1f1f658b7a5a373400b6f2d0445f421e5a3f783afcc25d5",
"moved": [
{
"source": "config",
"from": "scanners.skill_scanner.virustotal_api_key",
"to": ".env:VIRUSTOTAL_API_KEY",
"value": "<redacted>"
},
{
"source": "audit.db",
"from": "actions:r1",
"to": "asset_policy.skill.denied",
"value": "acme-notes"
}
],
"conflicts": [
{
"to": "admission.defaults.allow_list_bypass_scan",
"kept": "data.json:true",
"lost": "watch.allow_list_bypass_scan:false",
"reason": "enforcement read data.json; watch.allow_list_bypass_scan had no reader"
}
],
"removed": [
"skill_actions",
"watch.allow_list_bypass_scan",
"data.json:config.policy_name",
"scanners.skill_scanner.binary"
],
"actions_rows_moved": 4
}| Field | Meaning |
|---|---|
from_version, to_version | 8 and 9. |
migrated_at, actor | When it ran, and who: migration for the commands above, lifecycle for a managed host's ensure. |
source_sha256, result_sha256 | The hash of config.yaml before and after. The lifecycle uses them to see that an installed file is the migration of your v8 file, not drift. |
moved[] | Every value written. source is config, data.json or audit.db; from is the v8 location; to is the v9 key; value is the value written, with secrets shown as <redacted>. |
conflicts[] | A v9 key with disagreeing v8 sources: the kept and the lost value, and why. data.json (or, with none, the built-in defaults) wins over the *_actions keys and watch.allow_list_bypass_scan, because enforcement read it. A value config.yaml already sets under admission:, or in block_at or alert_at, wins over data.json. |
removed[] | v8 keys dropped without a v9 value because nothing read them. |
actions_rows_moved, actions_rows_ignored | The audit.db rows moved, and (managed host) left in place. |
notes[] | Behaviour changes to read, such as the threshold note above. |
renamed_rules[], expressed_rules[], detection_only_rules[] | The custom-pack rules the rebase changed: built-in rules you gave a pattern of your own and their new ID (CMD-RM-RF -> CUSTOM-CMD-RM-RF), the rules that still block with an expression derived from their literal pattern (and their literal anywhere in a tool call), and the ones that only record tool-call matches. |
rule_pack_rebase_failures[] | The custom packs pinned without their 1.0 copy, each with the reason; their pattern-only rules no longer block a tool call. |
unenforced_rego_data[] | The Rego modules that read a data.json key of yours that 1.0 no longer loads, each with the key and what happened to the module; the rules that read it are not enforced. |
unscannable_mcp[] | The MCP servers a scan refuses to start: name, connector, command, the scanner's reason, the runtime_effect and the fix command. Written by defenseclaw migrate after the commit. |
acknowledged | Set by --ack. |
Read the conflicts first. Each one is a place where your v8 files disagreed and
the migration kept what was actually enforced. If you meant the other value, set
it with defenseclaw config set as shown above.
Backups and the files it leaves
| File | What it is |
|---|---|
config.yaml.v8.bak | The exact bytes of your v8 config.yaml, with the same permissions. |
policies/rego/data.json.migrated-v9 | The retired data.json. |
migration-v9.json | The record described above. |
config.generation.json | The writer's record of the migration: actor migration and the reason config_version 9 migration. |
.env | Gains the VirusTotal key if config.yaml held it inline. |
policies/rego/<name>.rego.migrated-v9 | A pre-9 Rego module that was replaced. |
These are evidence, not policy: nothing reads them back. Keep them until you are sure you will not go back to 0.8.x, then delete them.
Doctor
defenseclaw doctor shows a Config migration row after a migration, until
you acknowledge the record. It warns when there are conflicts and passes when
there are none:
[WARN] Config migration — migrated to 9 on 2026-10-06: 31 value(s) moved, 2 conflict(s); details in ~/.defenseclaw/migration-v9.jsonRead the record, then acknowledge it:
defenseclaw config migrate --ackMarked the config_version 9 migration record as read.Doctor also warns about each server in unscannable_mcp that is still
configured with the same command, with the fix as the next step; the warning
stays after --ack and goes away once the entry uses npx, uvx or a URL:
[WARN] MCP server u33a-mcp (codex) — can no longer be scanned: command '/usr/bin/true' is a path, ...; still runs, without a scan (configured before the upgrade); ...
↪ Next step: defenseclaw mcp set u33a-mcp --command npx --args <package> --connector codex (or --url <url>)The migration row is gone on the next doctor run. A leftover policies/rego/data.json
is reported as Retired policy data and ignored; review it with
defenseclaw config get admission, then remove it.
Check the result
defenseclaw config get guardrail.block_at --effectiveHIGH
(source: config:guardrail.block_at)--effective prints the value in force and where it comes from. A value you
did not set prints the rule pack's default and names the pack as its source.
defenseclaw doctor reports the Policy row once the gateway runs, with the
generation and digest it applied.
Going back to 0.8.x
Except for a Windows 0.8.7-0.8.10 Setup install, use defenseclaw rollback
(Rollback). It restores the whole 0.8.x
install together with the data as it was when the upgrade started: the version
8 config.yaml, data.json and audit.db including the operator block and
allow rows. Anything you changed since the upgrade stays in
~/.defenseclaw/previous and returns if you roll forward.
For a Windows 0.8.7-0.8.10 Setup install, defenseclaw rollback refuses
automatic restoration and leaves the current install in place. Run
defenseclaw uninstall, then download DefenseClawSetup-x64.exe from the
release you had and run it in a desktop session. The old Setup files and your
data from before the upgrade are in
%USERPROFILE%\.defenseclaw\previous. See
Moving from the 0.8.x Setup package.
If you reinstall 0.8.x yourself instead, restore the configuration that
belonged to that release before starting its gateway. A migrated v9
config.yaml is too new for every 0.8.x release. Releases 0.8.4 and older
used config version 7; 0.8.5 through 0.8.10 used version 8. With the
migrated file in place, the 0.8.x CLI prints:
Configuration schema v8 is required — run 'defenseclaw upgrade' first.and the 0.8.x gateway will not load it ([config_version_unsupported] $.config_version: this configuration version is newer than the supported v8 contract). Both messages point at defenseclaw upgrade, which would take you
forward again. To stay on 0.8.x, go back by hand:
-
Stop the gateway:
defenseclaw-gateway stop. -
Restore
config.yamlfrom your pre-upgrade backup of the original 0.8.x install. For 0.8.4 or older, this must be the original version 7 file.config.yaml.v8.bakis the intermediate version 8 file produced by the first migration step and cannot substitute for that version 7 file. For 0.8.5 through 0.8.10,config.yaml.v8.bakis the version 8 pre-migration copy, if no later upgrade replaced it. -
Restore the matching pre-upgrade
data.jsonandaudit.dbfrom the same backup. If you have verified that only v9 moved them,policies/rego/data.json.migrated-v9contains the retired policy data;migration-v9.jsonlists block and allow rows moved fromaudit.db. -
Restore each saved pre-9 Rego module in the data directory:
cd ~/.defenseclaw/policies/rego for module in admission guardrail skill_actions; do if [ -f "$module.rego.migrated-v9" ]; then mv "$module.rego.migrated-v9" "$module.rego" fi doneThe v9 migration can replace
admission.regoandguardrail.regoand retireskill_actions.rego. Move any module the upgrade saved in~/.defenseclaw/backups/rego-<time>/(such assandbox.rego) back intopolicies/regoas well. If a saved copy is missing and the live module came from 1.0, restore that module from the matching 0.8.x policy bundle before starting the gateway. A restoredconfig.yamlalone does not restore the older admission decisions. -
If the legacy provider overlay was migrated, restore it in the configured data directory before starting the old gateway:
cd ~/.defenseclaw if [ -f custom-providers.json.migrated-v9 ]; then mv custom-providers.json.migrated-v9 custom-providers.json fiUse your configured
data_dirinstead of~/.defenseclawif it differs. An overlay outside the data home was left in place and needs no rename. -
Start the 0.8.x gateway and verify the restored settings.
If you do not have a matching pre-upgrade backup, use defenseclaw rollback
from the 1.x install instead (for a Windows 0.8.7-0.8.10 Setup install, the
Setup steps above). It restores the previous binary, config and data
together.
For a managed host, the older release must be given the version 8 admin config explicitly; see Lifecycle.
Configuration
~/.defenseclaw/config.yaml schema for config_version 9, on-disk layout, which files are policy, derived or evidence, reload and restart rules, and per-connector files. The place to look up "where does this setting live?"
Environment variables
Every environment variable DefenseClaw reads, grouped by category, with defaults, accepted values, and the files that read each one.