{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://raw.githubusercontent.com/sevten/pi-data-masking/main/masking.config.schema.json",
  "title": "pi-data-masking configuration",
  "description": "Configuration for project or global pi-data-masking rules. Rules run from top to bottom.",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string",
      "description": "JSON Schema URI used by editors for completion and validation."
    },
    "version": {
      "const": 1,
      "description": "Configuration format version. Legacy files may omit it."
    },
    "enabled": {
      "type": "boolean",
      "default": true,
      "description": "Top-level masking switch before any persistent global state saved from /masking."
    },
    "rules": {
      "type": "array",
      "default": [],
      "description": "Ordered rules. Earlier entries have higher priority.",
      "items": {
        "oneOf": [
          {
            "$ref": "#/$defs/literalRule"
          },
          {
            "$ref": "#/$defs/regexRule"
          }
        ]
      }
    },
    "options": {
      "$ref": "#/$defs/options",
      "description": "Settings are global-level: options are read from the global config only; an options object in a project config is ignored with a warning."
    }
  },
  "$defs": {
    "preserveStructure": {
      "type": "object",
      "additionalProperties": false,
      "description": "Retain non-secret shape in generated placeholders.",
      "properties": {
        "keepPrefix": {
          "oneOf": [
            {
              "type": "boolean"
            },
            {
              "type": "integer",
              "minimum": 0
            }
          ],
          "description": "Keep the first structural segment, optionally capped to this many characters."
        },
        "keepIPv4Octets": {
          "type": "integer",
          "minimum": 0,
          "maximum": 3,
          "description": "Keep this many leading IPv4 octets; at least one octet is randomized."
        }
      }
    },
    "literalRule": {
      "type": "object",
      "additionalProperties": false,
      "description": "Replace one exact value, supplied directly or through an environment variable.",
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "description": "Unique ID within this file."
        },
        "name": {
          "type": "string",
          "minLength": 1,
          "description": "Short human-readable rule name shown in the configuration center."
        },
        "type": {
          "const": "literal",
          "description": "Optional for literal rules."
        },
        "enabled": {
          "type": "boolean",
          "default": true
        },
        "description": {
          "type": "string"
        },
        "real": {
          "type": "string",
          "minLength": 1,
          "description": "Exact sensitive value. Prefer realFromEnv for secrets."
        },
        "realFromEnv": {
          "type": "string",
          "minLength": 1,
          "description": "Environment variable containing the exact sensitive value."
        },
        "placeholder": {
          "type": "string",
          "minLength": 1,
          "default": "auto"
        },
        "disclosePlaceholder": {
          "type": "boolean",
          "description": "Rule-level disclosure preference, applied when options.disclosePlaceholders is \"per-rule\"; omit or false to not disclose. While the global mode is true/false this value is paused (kept, not applied). Literal rules only."
        },
        "allowCommonPlaceholder": {
          "type": "boolean",
          "default": false,
          "description": "Acknowledge the accidental tool-restoration risk of using a bundled common semantic value as a custom placeholder."
        },
        "preserveStructure": {
          "$ref": "#/$defs/preserveStructure"
        },
        "restoreScope": {
          "$ref": "#/$defs/restoreScope"
        },
        "lowEntropy": {
          "type": "boolean",
          "default": false
        },
        "caseSensitive": {
          "type": "boolean",
          "default": true,
          "description": "Per-rule case-sensitivity switch (literal rules only; regex rules use flags). Omitted or true = case-sensitive matching; false = case-insensitive."
        }
      },
      "required": [
        "id"
      ],
      "oneOf": [
        {
          "required": [
            "real"
          ],
          "not": {
            "required": [
              "realFromEnv"
            ]
          }
        },
        {
          "required": [
            "realFromEnv"
          ],
          "not": {
            "required": [
              "real"
            ]
          }
        }
      ],
      "examples": [
        {
          "id": "prod_api_key",
          "realFromEnv": "PROD_API_KEY",
          "preserveStructure": {
            "keepPrefix": true
          }
        }
      ]
    },
    "regexRule": {
      "type": "object",
      "additionalProperties": false,
      "description": "Discover values matching a JavaScript regular expression. Fixed placeholders are not supported.",
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "description": "Unique ID within this file."
        },
        "name": {
          "type": "string",
          "minLength": 1,
          "description": "Short human-readable rule name shown in the configuration center."
        },
        "type": {
          "const": "regex"
        },
        "enabled": {
          "type": "boolean",
          "default": true
        },
        "description": {
          "type": "string"
        },
        "pattern": {
          "type": "string",
          "minLength": 1,
          "description": "JavaScript regex source without delimiters."
        },
        "flags": {
          "type": "string",
          "pattern": "^[dgimsuvy]*$",
          "description": "Optional JavaScript regex flags; global scanning and capture indices are added internally."
        },
        "preserveStructure": {
          "$ref": "#/$defs/preserveStructure"
        },
        "restoreScope": {
          "$ref": "#/$defs/restoreScope"
        },
        "lowEntropy": {
          "type": "boolean",
          "default": false
        }
      },
      "required": [
        "id",
        "type",
        "pattern"
      ],
      "examples": [
        {
          "id": "custom_token",
          "type": "regex",
          "pattern": "\\btoken_[A-Za-z0-9]{24}\\b"
        }
      ]
    },
    "options": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "showStatusBar": {
          "type": "boolean",
          "default": true
        },
        "systemPromptGuidance": {
          "type": "boolean",
          "default": false,
          "description": "Append model guidance to the system prompt establishing the behavioral contract for masked values: exact full-string comparison, verbatim passthrough into tool calls, transformations via tools, and no investigation of inconsistencies (ask the user instead). Cache-impacting when toggled mid-session."
        },
        "disclosePlaceholders": {
          "type": [
            "boolean",
            "string"
          ],
          "enum": [
            true,
            false,
            "per-rule"
          ],
          "default": false,
          "description": "Placeholder-disclosure master mode: true discloses all eligible literal rules; false discloses none; \"per-rule\" lets each rule's disclosePlaceholder decide (rules without it are not disclosed). In true/false mode rule-level values are paused, not lost. Requires systemPromptGuidance (auto-enabled otherwise). Regex placeholders are never listed."
        },
        "persistHistory": {
          "type": "boolean",
          "default": true
        },
        "allowlist": {
          "type": "array",
          "default": [],
          "description": "Literal text that is never masked: every boundary-aligned occurrence of each entry is left untouched, even when a rule would match inside it — so an entry may be a bare value (\"10.0.0.5\") or a whole line (\"Authorization: Bearer tok123\") containing one. A match continuing into a longer token run does not count (\"Bearer test\" never exempts \"Bearer test123\"). Each entry carries its own case-sensitivity flag (bare strings are case-sensitive). Global and project lists merge by union (project entries first).",
          "items": {
            "oneOf": [
              {
                "type": "string",
                "minLength": 1
              },
              {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "text": {
                    "type": "string",
                    "minLength": 1
                  },
                  "caseSensitive": {
                    "type": "boolean",
                    "default": true,
                    "description": "Omitted or true = case-sensitive matching; false = case-insensitive."
                  }
                },
                "required": [
                  "text"
                ]
              }
            ]
          }
        },
        "trustedDestinations": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^(\\*\\.[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+|[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+|\\d{1,3}(\\.\\d{1,3}){0,2}\\.\\*)$"
          },
          "uniqueItems": true,
          "description": "Global trusted destinations for scoped restoration: merged (union) into every rule's restoreScope destinations. Entries use restoreScope destination syntax: domains (dot-anchored suffix match, e.g. corp.internal covers api.corp.internal), single-label wildcards (*.corp), intranet wildcards (10.0.*), or IP literals. Widens destination checks only — tools checks and strict blocks on unextractable destinations are unaffected. Read from the global config only."
        },
        "persistedSecrets": {
          "type": "object",
          "description": "Protection for files that hold secret values in plaintext: the pi sessions directory (transcripts keep restored values verbatim) and this config file (every rule's real value). Local reads stay allowed; outbound sends are checked. Default: enabled, strict, never outbound.",
          "additionalProperties": false,
          "properties": {
            "enabled": {
              "type": "boolean",
              "default": true
            },
            "mode": {
              "type": "string",
              "enum": [
                "strict",
                "semi-strict",
                "permissive"
              ],
              "default": "strict"
            },
            "destinations": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "default": [],
              "description": "Destinations allowed to receive these files. Default [] = never outbound; options.trustedDestinations does not widen this list."
            },
            "onRefuse": {
              "type": "string",
              "enum": [
                "block",
                "ask"
              ],
              "default": "block"
            }
          }
        }
      }
    },
    "restoreScope": {
      "type": "object",
      "additionalProperties": false,
      "description": "Fully explicit restoration scope: either absent (restore unconditionally) or complete. mode and onRefuse are required.",
      "properties": {
        "destinations": {
          "type": "array",
          "uniqueItems": true,
          "items": {
            "type": "string",
            "minLength": 1,
            "pattern": "^(\\*\\.[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+|[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+|\\d{1,3}(\\.\\d{1,3}){0,2}\\.\\*)$"
          },
          "description": "Hosts this rule's values may be sent to: domains (dot-anchored suffix match, so stripe.com covers api.stripe.com but not stripe.com.evil.com), IP literals, or intranet wildcards (*.internal.acme.com matches one label; 10.0.* matches one or more trailing labels). An empty array means the value may never go outbound (local use only); the field being absent means no destination restriction. For preset rules an explicit list REPLACES the preset's built-in destinations."
        },
        "tools": {
          "type": "array",
          "minItems": 1,
          "uniqueItems": true,
          "items": {
            "type": "string",
            "minLength": 1
          },
          "description": "Tool names allowed to receive the restored value (e.g. write, edit) — for values used locally rather than sent to a service."
        },
        "envNames": {
          "type": "array",
          "minItems": 1,
          "uniqueItems": true,
          "items": {
            "type": "string",
            "minLength": 1
          },
          "description": "Environment variable names bound to this rule; defaults to realFromEnv when the rule reads its value from the environment."
        },
        "mode": {
          "enum": [
            "strict",
            "semi-strict",
            "permissive"
          ],
          "description": "What to restore, per situation: strict (verified destinations only), semi-strict (also local use), permissive (also unverifiable outbound, warned). Required."
        },
        "onRefuse": {
          "enum": [
            "block",
            "ask"
          ],
          "description": "What to do when the mode refuses: block (refuse the call) or ask (pause for user approval). Required."
        }
      },
      "required": [
        "mode",
        "onRefuse"
      ]
    }
  },
  "examples": [
    {
      "$schema": "https://raw.githubusercontent.com/sevten/pi-data-masking/main/masking.config.schema.json",
      "version": 1,
      "enabled": true,
      "rules": [
        {
          "id": "prod_api_key",
          "realFromEnv": "PROD_API_KEY"
        }
      ]
    }
  ]
}
