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

InstallWhat 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 profileNo change. It keeps its existing path: the gateway does not migrate its config in memory.
A fresh defenseclaw initinit 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

CommandWhat it does
defenseclaw upgradeThe 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 migrateBrings 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 reloadA 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-run
Would 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=disable

Each 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-run

Running the same command without --dry-run migrates for real:

defenseclaw config migrate

When 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.

policies/rego/data.json (v8, excerpt)
{
  "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"
  }
}
config.yaml (v9, excerpt)
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, quarantine or warn when it matches one; any other combination is written as the exact install, file and runtime triple, as the MCP low entry shows. The MCP entries appear because 0.8.x shipped stricter MCP overrides in scanner_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_install and allow_list_bypass_scan are written to admission.defaults only when data.json turned them off (for allow_list_bypass_scan, also when it left the key out, because 0.8.x bypassed the scan only for an explicit true).
  • first_party_allow_list is 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, and policy list still marks the policy you activated.
  • A first_party_allow_list entry without source_path_contains matched any path in v8, and skipped the scan only where allow_list_bypass_scan was on. A v9 first-party entry needs a path, so where the bypass applies to its type the entry becomes a name-only asset_policy.<type>.allowed entry. Where it does not, v8 scanned the asset anyway: the entry is dropped and a note is recorded.
  • A value config.yaml already sets under admission: wins. Before the migration has run, defenseclaw config set, policy activate and the TUI write such values into the version 8 file, and the gateway applies them when it reads that file in memory. Where data.json held a different value, a conflict is recorded that keeps the config value.
  • cisco_trust_level is written when it is not full.
  • 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 under removed in 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 v8In v9
data.json is stricter than the rule pack's defaultWritten to guardrail.block_at or alert_at.
data.json holds the shipped valueNot written; the proxy now follows the rule pack's default. A note is recorded.
data.json is looser than the rule pack's defaultNot written. The pack default stays and a conflict is recorded.
config.yaml already sets block_at or alert_atThe 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.

config.yaml (v8)
skill_actions:
  medium:
    file: quarantine
    runtime: disable
    install: block
migration-v9.json (excerpt)
{
  "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 quarantine

watch.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:

config.yaml (v8)
watch:
  allow_list_bypass_scan: false
migration-v9.json (excerpt)
{
  "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 false

Rule 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).

config.yaml (v8)
guardrail:
  connectors:
    codex:
      rule_pack_dir: /home/<you>/.defenseclaw/policies/guardrail/strict
    cursor:
      rule_pack_dir: /etc/acme/defenseclaw/packs/acme-strict
config.yaml (v9)
guardrail:
  connectors:
    codex:
      rule_pack: strict
    cursor:
      rule_pack: acme-strict
  custom_packs:
    acme-strict:
      path: /etc/acme/defenseclaw/packs/acme-strict
      digest: sha256:47c16bf9651541dfe808ef1f6127b7a65f6f7982b99d1739e334633c88d2e9f5
rule_pack_dir in v8rule_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 folderA 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 pattern is a plain literal (such as acme-marker-7f3c or acme\.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 \b word boundaries (\bacme-marker\b) still has to be a whole word, as in 0.8.x. The upgrade and defenseclaw doctor name these rules;

  • a built-in rule whose pattern you changed is kept as a rule of yours under an ID of its own, CUSTOM-<ID> (CMD-RM-RF becomes CUSTOM-CMD-RM-RF, or CUSTOM-CMD-RM-RF-2 when 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 (a CMD-RM-RF match 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-1 from rules/acme-extra.yaml becomes ACME-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:

Upgrade output
⚠ 2 custom rule(s) now detection-only for tool calls: ACME-SPACED, ACME-REGEX; add an expression, see policies/rules

The 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):

Upgrade output
⚠ 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 content

defenseclaw 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).

config.yaml (v8, a protected-pack folder)
guardrail:
  rule_pack_dir: /home/<you>/.defenseclaw/policies/guardrail/protected-claudecode/strict
config.yaml (v9)
guardrail:
  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.

config.yaml (v8)
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_KEY
config.yaml (v9)
scanners:
  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 keyv9
scanners.skill_scanner.binary, scanners.mcp_scanner.binaryRemoved. DefenseClaw runs the scanners it ships.
scanners.skill_scanner.policy empty or missingpolicy: quiet, the recommended preset. This is the one value an unmodified install moves.
policy: strict, balanced, permissive, low-noise or quietKept.
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_virustotalanalyzers.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_envanalyzers.virustotal.api_key_env.
use_aidefenseanalyzers.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 blockjudge_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

config.yaml (v8)
update_check: false
config.yaml (v9)
update:
  check: false

Only 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:

RowTypeNameStateReason
r1skillacme-notes (connector claudecode)install: blockvendor not reviewed
r2skillacme-lintinstall: allowreviewed by security
r3mcpacme-wikiinstall: blocksends data to an unknown host
r4skillscratch-toolinstall: block, file: quarantineauto-block: HIGH findings
r5tool@claudecode/WebFetchinstall: blockno web fetch in CI
config.yaml (v9, excerpt)
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 r4 stays in audit.db. A block that a scan verdict wrote (a reason that starts auto-block, post-scan:, post-install scan: or scan:) is the enforcement journal, not an operator decision. Quarantine and runtime-disable state is unchanged.
  • A moved row loses its install field after config.yaml is 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 a connector and a name.
  • On a managed standalone host the rows are not moved. The record counts them in actions_rows_ignored and adds the note local_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:

config.yaml (v9, excerpt)
ai_discovery:
  signature_packs:
    - /home/alice/.defenseclaw/signature-packs/acme-agents.json
  signature_pack_digests:
    /home/alice/.defenseclaw/signature-packs/acme-agents.json: sha256:042a9931f9f1cd782229ec4eff49a4b40c946728bc3675b9184d457c4f1bf8a5

The 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 keyNow
policies/rego/data.jsonIgnored. Renamed to data.json.migrated-v9 by the migration.
skill_actions, mcp_actions, plugin_actionsadmission
watch.allow_list_bypass_scanadmission.defaults.allow_list_bypass_scan
rule_pack_dir (every scope)rule_pack, custom_packs, rules
scanners.skill_scanner.binary, scanners.mcp_scanner.binaryRemoved
scanners.skill_scanner.use_virustotal, use_aidefense, virustotal_api_key, virustotal_api_key_envscanners.skill_scanner.analyzers.*
update_checkupdate.check
Operator rows in the audit.db actions tableasset_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 list shows it as scan failed with 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_action is false).
  • 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 npx or uvx with its package, or at the server's URL. When the command is a path to npx or uvx, the fix keeps your arguments and uses the bare name.
  • Claude Code: 0.8.x wrote mcp set entries into the mcpServers block of ~/.claude/settings.json. 1.0 writes them to ~/.claude.json ($CLAUDE_CONFIG_DIR/.claude.json when that is set), the file Claude Code reads. DefenseClaw still reads the old block, after ~/.claude.json, and mcp list names an entry that is only there. The fix command with --connector claudecode writes the entry to ~/.claude.json and removes the copy from settings.json; defenseclaw mcp unset <name> --connector claudecode removes 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):

migration-v9.json (excerpt)
{
  "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
}
FieldMeaning
from_version, to_version8 and 9.
migrated_at, actorWhen it ran, and who: migration for the commands above, lifecycle for a managed host's ensure.
source_sha256, result_sha256The 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_ignoredThe 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.
acknowledgedSet 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

FileWhat it is
config.yaml.v8.bakThe exact bytes of your v8 config.yaml, with the same permissions.
policies/rego/data.json.migrated-v9The retired data.json.
migration-v9.jsonThe record described above.
config.generation.jsonThe writer's record of the migration: actor migration and the reason config_version 9 migration.
.envGains the VirusTotal key if config.yaml held it inline.
policies/rego/<name>.rego.migrated-v9A 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.json

Read the record, then acknowledge it:

defenseclaw config migrate --ack
Marked 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 --effective
HIGH
(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:

  1. Stop the gateway: defenseclaw-gateway stop.

  2. Restore config.yaml from 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.bak is 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.bak is the version 8 pre-migration copy, if no later upgrade replaced it.

  3. Restore the matching pre-upgrade data.json and audit.db from the same backup. If you have verified that only v9 moved them, policies/rego/data.json.migrated-v9 contains the retired policy data; migration-v9.json lists block and allow rows moved from audit.db.

  4. 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
    done

    The v9 migration can replace admission.rego and guardrail.rego and retire skill_actions.rego. Move any module the upgrade saved in ~/.defenseclaw/backups/rego-<time>/ (such as sandbox.rego) back into policies/rego as 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 restored config.yaml alone does not restore the older admission decisions.

  5. 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
    fi

    Use your configured data_dir instead of ~/.defenseclaw if it differs. An overlay outside the data home was left in place and needs no rename.

  6. 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.