All Articles Claude Code

Dynamic Hook Configuration: Runtime Rules for Claude Code

You've built a solid hook system. Security checks, logging, formatting—all working beautifully.

You’ve built a solid hook system. Security checks, logging, formatting—all working beautifully. Then your team asks a question that changes everything: “Can we update security rules without redeploying code?”

Welcome to the real world. Hardcoded rules don’t scale. When a new vulnerability emerges, you shouldn’t need to update JavaScript and restart Claude. When staging needs different validation than production, you shouldn’t fork your entire hook codebase. When your ops team needs to disable a rule temporarily for a migration, they shouldn’t need to wait for an engineer.

This is where dynamic hook configuration comes in. Instead of rules baked into code, you load them from external files at runtime. JSON, YAML, environment variables—whatever format makes sense. Your ops team updates a config file. Claude reloads it on the next session. Rules change. No deployment. No restart. No friction.

But dynamic config is deceptively hard. How do you validate rules before applying them? How do you handle failures gracefully? How do you support multiple environments? How do you roll back a bad config?

By the end of this guide, you’ll build a complete rule engine: loading rules from files, validating them, applying environment-specific overrides, hot-reloading on changes, and letting non-developers safely manage hook behavior. You’ll understand the patterns that make dynamic configuration production-ready.

Why Static Rules Don’t Work

Let’s start with why we need this. Here’s a typical scenario:

Version 1: Hardcoded Rules

// hook.js - Rules baked in
const MAX_FILE_SIZE = 10485760; // 10 MB hardcoded
const BLOCKED_EXTENSIONS = ['.exe', '.dll', '.sh'];
const REQUIRE_APPROVAL_THRESHOLD = 5; // MB

// Want to change max file size? Edit code, test, deploy, restart Claude.

This works until:
– Your staging environment needs different limits than production
– A critical security threshold changes mid-day
– Your ops team needs to adjust rules without engineering involvement
– You have ten different Claude instances with ten different rule sets

Version 2: Environment Variables

// Better, but limited
const MAX_FILE_SIZE = process.env.MAX_FILE_SIZE || 10485760;

Still breaks when:
– Rules are complex (more than simple numbers)
– You need to disable specific rules temporarily
– You want centralized rule management
– You need audit trails of who changed what

Version 3: Dynamic Config Files
Load rules from JSON/YAML at runtime. Update files. Claude reloads automatically. Rules change without code changes.

That’s the move. And we’re building it now.

The Configuration Architecture

Here’s the system we’re building:

Config File (JSON/YAML)
    ↓
Config Loader (validates schema)
    ↓
Environment Overrides (dev/staging/prod)
    ↓
Rule Engine (enforces rules)
    ↓
Hook Execution (uses rules)
    ↓
Change Detector (auto-reload on update)

The key insight: config files are code. They need schemas, validation, versioning, and rollback plans. Treat them seriously.

Building the Config Loader

Let’s start with the foundation: loading and validating configuration files.

File: hook-config-loader.mjs





/**
 * Configuration Loader with Schema Validation
 * Loads rules from JSON/YAML files with environment overrides
 */

class ConfigLoader {
  constructor(configPath, options = {}) {
    this.configPath = configPath;
    this.options = {
      validateSchema: true,
      cacheConfig: true,
      maxFileSize: 5 * 1024 * 1024, // 5 MB
      ...options
    };

    this.config = null;
    this.configHash = null;
    this.lastLoadTime = null;
    this.schema = this.getDefaultSchema();
  }

  /**
   * Load configuration from file with validation
   */
  async load() {
    try {
      const filePath = this.resolveConfigPath(this.configPath);

      // Check file exists and size
      if (!fs.existsSync(filePath)) {
        throw new Error(`Config file not found: ${filePath}`);
      }

      const stats = fs.statSync(filePath);
      if (stats.size > this.options.maxFileSize) {
        throw new Error(`Config file exceeds max size: ${stats.size} bytes`);
      }

      // Read and parse file
      const fileContent = fs.readFileSync(filePath, 'utf8');
      const rawConfig = this.parseFile(filePath, fileContent);

      // Validate schema
      if (this.options.validateSchema) {
        this.validateSchema(rawConfig);
      }

      // Apply environment overrides
      const finalConfig = this.applyEnvironmentOverrides(rawConfig);

      // Cache configuration
      this.config = finalConfig;
      this.configHash = this.hashContent(fileContent);
      this.lastLoadTime = new Date();

      console.log(`[ConfigLoader] Loaded config from ${filePath}`);
      return finalConfig;

    } catch (error) {
      console.error(`[ConfigLoader] Failed to load config: ${error.message}`);
      throw error;
    }
  }

  /**
   * Parse YAML or JSON based on file extension
   */
  parseFile(filePath, content) {
    const ext = path.extname(filePath).toLowerCase();

    if (ext === '.yml' || ext === '.yaml') {
      return YAML.parse(content);
    } else if (ext === '.json') {
      return JSON.parse(content);
    } else {
      throw new Error(`Unsupported config format: ${ext}`);
    }
  }

  /**
   * Resolve config path (supports relative, absolute, env vars)
   */
  resolveConfigPath(configPath) {
    if (process.env.CLAUDE_CONFIG_PATH) {
      return process.env.CLAUDE_CONFIG_PATH;
    }

    if (path.isAbsolute(configPath)) {
      return configPath;
    }

    return path.resolve(process.cwd(), configPath);
  }

  /**
   * Validate config matches schema
   */
  validateSchema(config) {
    const errors = [];

    // Check required fields
    for (const field of this.schema.required) {
      if (!(field in config)) {
        errors.push(`Missing required field: ${field}`);
      }
    }

    // Validate field types
    for (const [field, type] of Object.entries(this.schema.properties)) {
      if (field in config) {
        if (typeof config[field] !== type) {
          errors.push(`Field ${field} must be ${type}, got ${typeof config[field]}`);
        }
      }
    }

    if (errors.length > 0) {
      throw new Error(`Schema validation failed:\n${errors.join('\n')}`);
    }
  }

  /**
   * Apply environment-specific overrides (dev/staging/prod)
   */
  applyEnvironmentOverrides(config) {
    const env = process.env.NODE_ENV || 'development';
    const overrideKey = `_${env}`;

    if (overrideKey in config) {
      const overrides = config[overrideKey];
      console.log(`[ConfigLoader] Applying ${env} environment overrides`);

      return {
        ...config,
        ...overrides,
        [overrideKey]: overrides // Keep original for reference
      };
    }

    return config;
  }

  /**
   * Get default configuration schema
   */
  getDefaultSchema() {
    return {
      required: ['version', 'rules'],
      properties: {
        version: 'string',
        name: 'string',
        description: 'string',
        rules: 'object',
        policies: 'object'
      }
    };
  }

  /**
   * Simple hash for detecting config changes
   */
  hashContent(content) {
    let hash = 0;
    for (let i = 0; i < content.length; i++) {
      const char = content.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // Convert to 32-bit integer
    }
    return Math.abs(hash).toString(16);
  }

  /**
   * Check if config file has changed since last load
   */
  hasChanged() {
    try {
      const filePath = this.resolveConfigPath(this.configPath);
      const fileContent = fs.readFileSync(filePath, 'utf8');
      const currentHash = this.hashContent(fileContent);
      return currentHash !== this.configHash;
    } catch (error) {
      console.error(`[ConfigLoader] Error checking for changes: ${error.message}`);
      return false;
    }
  }

  /**
   * Get current loaded configuration
   */
  getConfig() {
    if (!this.config) {
      throw new Error('Configuration not loaded. Call load() first.');
    }
    return this.config;
  }

  /**
   * Get specific rule from configuration
   */
  getRule(ruleName, defaultValue = null) {
    if (!this.config) {
      return defaultValue;
    }

    const rules = this.config.rules || {};
    return ruleName in rules ? rules[ruleName] : defaultValue;
  }

  /**
   * Get specific policy from configuration
   */
  getPolicy(policyName, defaultValue = null) {
    if (!this.config) {
      return defaultValue;
    }

    const policies = this.config.policies || {};
    return policyName in policies ? policies[policyName] : defaultValue;
  }
}

export default ConfigLoader;

This loader does the heavy lifting:
– Parses JSON and YAML
– Validates against a schema
– Applies environment-specific overrides
– Detects file changes
– Provides a clean API for accessing rules

Now let’s create a practical configuration file format.

Defining Configuration Schema

File: hook-config-example.yaml

# Hook Configuration - Production
version: "2.0.0"
name: "Claude Code Hook Rules"
description: "Dynamic configuration for hook behavior"

rules:
  # File operation rules
  file:
    maxFileSize: 10485760  # 10 MB
    maxFilesPerBatch: 50
    blockedExtensions:
      - .exe
      - .dll
      - .sh
      - .bat
    allowedDirectories:
      - /projects
      - /articles
    blockedDirectories:
      - /node_modules
      - /.git
      - /dist

  # Git operation rules
  git:
    requireBranchPrefix: "feature/"
    requireCommitSignature: false
    maxCommitMessageLength: 100
    minCommitMessageLength: 10
    blockedBranches:
      - main
      - master
      - production

  # Code quality rules
  quality:
    minTestCoverage: 0.75
    requireApprovals: 1
    maxCyclomaticComplexity: 15
    lintOnWrite: true
    formatOnWrite: true

  # Security rules
  security:
    scanForSecrets: true
    requireReview: false
    allowedEnvironments:
      - NODE_ENV
      - NPM_TOKEN
    blockedEnvironments:
      - AWS_SECRET_ACCESS_KEY
      - PRIVATE_KEY

  # Logging rules
  logging:
    logAllOperations: true
    logLevel: "info"
    retentionDays: 30
    sensitiveFields:
      - password
      - token
      - secret
      - apiKey

policies:
  # Approval workflows
  approvals:
    deletionRequiresApproval: true
    productionDeployRequiresApproval: true
    securityRuleChangesRequireApproval: true

  # Cost controls
  costControl:
    enableCostTracking: true
    alertOnCostExceedance: true
    costThresholdDaily: 100.00
    costThresholdMonthly: 2000.00

  # Rate limiting
  rateLimits:
    enableRateLimiting: true
    requestsPerMinute: 60
    requestsPerHour: 3000

# Staging environment overrides
_staging:
  rules:
    file:
      maxFileSize: 5242880  # 5 MB in staging
      blockedDirectories: []  # Allow everything in staging
    git:
      requireBranchPrefix: null
      requireCommitSignature: false
    quality:
      minTestCoverage: 0.50  # Lower bar in staging
  policies:
    approvals:
      deletionRequiresApproval: false

# Development environment overrides
_development:
  rules:
    file:
      maxFileSize: 104857600  # 100 MB locally
    git:
      requireBranchPrefix: null
      requireCommitSignature: false
    quality:
      minTestCoverage: 0.00  # No requirement in dev
      lintOnWrite: false
      formatOnWrite: false
  policies:
    approvals:
      deletionRequiresApproval: false
      productionDeployRequiresApproval: false

This schema covers three real-world domains: file operations, code quality, and security. Each environment can override rules as needed.

Building the Rule Engine

Now we need something to actually enforce these rules. Let’s build a rule engine.

File: hook-rule-engine.mjs

/**
 * Rule Engine - Evaluates and enforces hook rules
 * Applies configuration rules to hook operations
 */

class RuleEngine {
  constructor(configLoader) {
    this.configLoader = configLoader;
    this.evaluators = new Map();
    this.violations = [];
    this.registerDefaultEvaluators();
  }

  /**
   * Register built-in rule evaluators
   */
  registerDefaultEvaluators() {
    this.registerEvaluator('fileSizeLimit', (config, context) => {
      const rule = config.getRule('file.maxFileSize', Infinity);
      const fileSize = context.fileSize || 0;

      if (fileSize > rule) {
        return {
          passed: false,
          message: `File size ${fileSize} bytes exceeds limit of ${rule} bytes`,
          severity: 'error'
        };
      }
      return { passed: true };
    });

    this.registerEvaluator('blockedExtension', (config, context) => {
      const blockedExts = config.getRule('file.blockedExtensions', []);
      const fileName = context.fileName || '';
      const ext = fileName.substring(fileName.lastIndexOf('.'));

      if (blockedExts.includes(ext)) {
        return {
          passed: false,
          message: `File extension ${ext} is blocked`,
          severity: 'error'
        };
      }
      return { passed: true };
    });

    this.registerEvaluator('allowedDirectory', (config, context) => {
      const allowedDirs = config.getRule('file.allowedDirectories', []);
      const filePath = context.filePath || '';

      if (allowedDirs.length > 0) {
        const isAllowed = allowedDirs.some(dir => filePath.startsWith(dir));
        if (!isAllowed) {
          return {
            passed: false,
            message: `File path ${filePath} not in allowed directories`,
            severity: 'error'
          };
        }
      }
      return { passed: true };
    });

    this.registerEvaluator('blockedDirectory', (config, context) => {
      const blockedDirs = config.getRule('file.blockedDirectories', []);
      const filePath = context.filePath || '';

      const isBlocked = blockedDirs.some(dir => filePath.includes(dir));
      if (isBlocked) {
        return {
          passed: false,
          message: `File path ${filePath} contains blocked directory`,
          severity: 'error'
        };
      }
      return { passed: true };
    });

    this.registerEvaluator('gitBranchPrefix', (config, context) => {
      const requiredPrefix = config.getRule('git.requireBranchPrefix');
      const branchName = context.branchName || '';

      if (requiredPrefix && !branchName.startsWith(requiredPrefix)) {
        return {
          passed: false,
          message: `Branch "${branchName}" must start with "${requiredPrefix}"`,
          severity: 'warning'
        };
      }
      return { passed: true };
    });

    this.registerEvaluator('securityScan', (config, context) => {
      const scanEnabled = config.getRule('security.scanForSecrets', false);
      if (!scanEnabled) return { passed: true };

      const content = context.content || '';
      const suspiciousPatterns = [
        /password\s*[:=]/i,
        /api[_-]?key\s*[:=]/i,
        /secret\s*[:=]/i,
        /token\s*[:=]/i
      ];

      for (const pattern of suspiciousPatterns) {
        if (pattern.test(content)) {
          return {
            passed: false,
            message: 'Content appears to contain secrets',
            severity: 'error'
          };
        }
      }
      return { passed: true };
    });
  }

  /**
   * Register a custom rule evaluator
   */
  registerEvaluator(ruleName, evaluatorFn) {
    this.evaluators.set(ruleName, evaluatorFn);
  }

  /**
   * Evaluate a single rule
   */
  evaluateRule(ruleName, context) {
    const evaluator = this.evaluators.get(ruleName);
    if (!evaluator) {
      console.warn(`[RuleEngine] Unknown rule evaluator: ${ruleName}`);
      return { passed: true, message: 'Rule not found (ignored)' };
    }

    try {
      return evaluator(this.configLoader, context);
    } catch (error) {
      return {
        passed: false,
        message: `Rule evaluation error: ${error.message}`,
        severity: 'error'
      };
    }
  }

  /**
   * Evaluate multiple rules against context
   * Returns comprehensive violation report
   */
  evaluate(ruleNames, context) {
    const results = {
      passed: true,
      violations: [],
      warnings: []
    };

    for (const ruleName of ruleNames) {
      const result = this.evaluateRule(ruleName, context);

      if (!result.passed) {
        const violation = {
          rule: ruleName,
          message: result.message,
          severity: result.severity || 'error',
          context: context
        };

        if (result.severity === 'warning') {
          results.warnings.push(violation);
        } else {
          results.violations.push(violation);
          results.passed = false;
        }
      }
    }

    return results;
  }

  /**
   * Evaluate all rules for a specific rule group
   */
  evaluateGroup(groupName, context) {
    const group = this.configLoader.getRule(groupName);
    if (!group || typeof group !== 'object') {
      return { passed: true, violations: [], warnings: [] };
    }

    // Map rule names in group to evaluators
    const ruleNames = Object.keys(this.evaluators)
      .filter(rule => rule.startsWith(groupName));

    return this.evaluate(ruleNames, context);
  }

  /**
   * Get all registered evaluator names
   */
  getEvaluators() {
    return Array.from(this.evaluators.keys());
  }

  /**
   * Format violations for display
   */
  formatViolations(results) {
    let output = '';

    if (results.violations.length > 0) {
      output += `\n[VIOLATIONS] ${results.violations.length} rule(s) failed:\n`;
      for (const v of results.violations) {
        output += `  ✗ ${v.rule}: ${v.message}\n`;
      }
    }

    if (results.warnings.length > 0) {
      output += `\n[WARNINGS] ${results.warnings.length} rule(s) need attention:\n`;
      for (const w of results.warnings) {
        output += `  ⚠ ${w.rule}: ${w.message}\n`;
      }
    }

    return output;
  }
}

export default RuleEngine;

The rule engine is a registry of evaluators. Each evaluator checks one rule. You can register custom evaluators for domain-specific logic.

Hot-Reloading Configuration

Static config files aren’t dynamic if you can’t reload them. Let’s build hot-reload support.

File: hook-config-watcher.mjs




/**
 * Configuration Watcher - Detects and reloads config changes
 * Auto-reloads hook rules when config files change
 */

class ConfigWatcher {
  constructor(configLoader, options = {}) {
    this.configLoader = configLoader;
    this.options = {
      debounceMs: 1000,
      maxAttempts: 3,
      ...options
    };

    this.watcher = null;
    this.debounceTimer = null;
    this.listeners = new Set();
    this.reloadCount = 0;
  }

  /**
   * Start watching config file for changes
   */
  start() {
    const filePath = this.configLoader.resolveConfigPath(
      this.configLoader.configPath
    );

    try {
      const dir = path.dirname(filePath);
      const filename = path.basename(filePath);

      this.watcher = fs.watch(dir, (eventType, changedFile) => {
        if (changedFile === filename) {
          this.handleChange();
        }
      });

      console.log(`[ConfigWatcher] Watching ${filePath}`);
    } catch (error) {
      console.error(`[ConfigWatcher] Failed to start watching: ${error.message}`);
    }
  }

  /**
   * Stop watching config file
   */
  stop() {
    if (this.watcher) {
      this.watcher.close();
      this.watcher = null;
      console.log('[ConfigWatcher] Stopped watching');
    }
  }

  /**
   * Handle config file change with debounce
   */
  handleChange() {
    // Debounce: wait for changes to settle
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    this.debounceTimer = setTimeout(() => {
      this.reload();
    }, this.options.debounceMs);
  }

  /**
   * Reload configuration from disk
   */
  async reload() {
    try {
      console.log('[ConfigWatcher] Config file changed, reloading...');

      // Validate new config before applying
      const tempConfig = this.configLoader.config;

      try {
        await this.configLoader.load();
        this.reloadCount++;
        console.log(`[ConfigWatcher] Config reloaded successfully (attempt ${this.reloadCount})`);

        // Notify listeners
        this.notifyListeners({
          event: 'reload',
          success: true,
          timestamp: new Date(),
          configHash: this.configLoader.configHash
        });

      } catch (loadError) {
        // Rollback to previous config on load failure
        console.error(`[ConfigWatcher] Reload failed: ${loadError.message}`);
        this.configLoader.config = tempConfig;

        this.notifyListeners({
          event: 'reload_failed',
          success: false,
          error: loadError.message,
          timestamp: new Date()
        });
      }

    } catch (error) {
      console.error(`[ConfigWatcher] Unexpected error during reload: ${error.message}`);
    }
  }

  /**
   * Subscribe to reload events
   */
  on(listener) {
    this.listeners.add(listener);
  }

  /**
   * Unsubscribe from reload events
   */
  off(listener) {
    this.listeners.delete(listener);
  }

  /**
   * Notify all listeners of changes
   */
  notifyListeners(event) {
    for (const listener of this.listeners) {
      try {
        listener(event);
      } catch (error) {
        console.error(`[ConfigWatcher] Listener error: ${error.message}`);
      }
    }
  }

  /**
   * Get reload statistics
   */
  getStats() {
    return {
      isWatching: this.watcher !== null,
      reloadCount: this.reloadCount,
      lastLoadTime: this.configLoader.lastLoadTime,
      currentHash: this.configLoader.configHash
    };
  }
}

export default ConfigWatcher;

This watcher monitors the config file and reloads it when it changes. Note the rollback logic—if the new config is invalid, we revert to the previous one.

Integrating with Hooks

Now let’s bring it all together: using dynamic config in an actual hook.

File: postToolUse-enforce-rules.mjs





/**
 * PostToolUse Hook - Enforce Dynamic Rules
 * Validates tool operations against loaded configuration
 */

let configLoader = null;
let ruleEngine = null;
let configWatcher = null;

/**
 * Initialize hook with config
 */
export async function initialize(configPath = '.claude/hook-config.yaml') {
  try {
    configLoader = new ConfigLoader(configPath);
    await configLoader.load();

    ruleEngine = new RuleEngine(configLoader);

    configWatcher = new ConfigWatcher(configLoader);
    configWatcher.on((event) => {
      if (event.event === 'reload') {
        console.log('[Hook] Configuration reloaded, rules now in effect');
      } else if (event.event === 'reload_failed') {
        console.warn(`[Hook] Configuration reload failed: ${event.error}`);
      }
    });
    configWatcher.start();

    console.log('[Hook] Initialized with dynamic configuration');
  } catch (error) {
    console.error(`[Hook] Initialization failed: ${error.message}`);
    throw error;
  }
}

/**
 * PostToolUse Hook Entry Point
 */
export default async function postToolUseHook(context) {
  if (!configLoader || !ruleEngine) {
    console.warn('[Hook] Not initialized, skipping enforcement');
    return { allow: true };
  }

  try {
    // Get tool operation details
    const toolName = context.toolName || '';
    const toolInput = context.toolInput || {};

    // Build evaluation context
    const evalContext = buildEvaluationContext(toolName, toolInput);

    // Determine which rules to apply based on tool type
    const rulesToCheck = getRulesForTool(toolName);

    // Evaluate rules
    const result = ruleEngine.evaluate(rulesToCheck, evalContext);

    // Log result
    if (!result.passed) {
      console.log(`[Hook] Rule violations detected for ${toolName}`);
      console.log(ruleEngine.formatViolations(result));
    }

    // Return enforcement decision
    return {
      allow: result.passed,
      violations: result.violations,
      warnings: result.warnings,
      configHash: configLoader.configHash
    };

  } catch (error) {
    console.error(`[Hook] Error during rule evaluation: ${error.message}`);
    return { allow: false, error: error.message };
  }
}

/**
 * Build context object for rule evaluation
 */
function buildEvaluationContext(toolName, toolInput) {
  const context = {
    tool: toolName,
    timestamp: new Date().toISOString()
  };

  switch (toolName) {
    case 'Write':
    case 'Edit':
      context.fileName = toolInput.file_path?.split('/').pop() || '';
      context.filePath = toolInput.file_path || '';
      context.fileSize = toolInput.content?.length || 0;
      context.content = toolInput.content || '';
      break;

    case 'Bash':
      context.command = toolInput.command || '';
      context.commandType = classifyCommand(toolInput.command);
      break;

    case 'Read':
      context.filePath = toolInput.file_path || '';
      context.sensitive = isSensitiveFile(toolInput.file_path);
      break;

    default:
      context.raw = toolInput;
  }

  return context;
}

/**
 * Determine which rules apply to specific tool
 */
function getRulesForTool(toolName) {
  const rules = [
    'securityScan', // All tools get security checks
  ];

  switch (toolName) {
    case 'Write':
    case 'Edit':
      rules.push('fileSizeLimit', 'blockedExtension', 'allowedDirectory', 'blockedDirectory');
      break;

    case 'Bash':
      // Add bash-specific rules
      break;

    case 'Read':
      // Add read-specific rules
      break;
  }

  return rules;
}

/**
 * Classify bash command type
 */
function classifyCommand(command) {
  if (/^rm\s/.test(command)) return 'delete';
  if (/^git\s/.test(command)) return 'git';
  if (/^git\s+push\s+--force/.test(command)) return 'force_push';
  return 'other';
}

/**
 * Check if file path is sensitive
 */
function isSensitiveFile(filePath) {
  const sensitivePatterns = [
    /\.env/,
    /secret/i,
    /credential/i,
    /\.ssh/,
    /config\.json/
  ];

  return sensitivePatterns.some(pattern => pattern.test(filePath));
}

/**
 * Cleanup function
 */
export function cleanup() {
  if (configWatcher) {
    configWatcher.stop();
  }
}

This hook ties everything together. When Claude uses a tool, the hook:
1. Loads rules from the dynamic config
2. Evaluates the operation against those rules
3. Allows or blocks based on violations
4. Hot-reloads if the config changes

Practical Configuration Management

Let’s look at real-world scenarios.

File: config-management-examples.mjs

/**
 * Configuration Management Utilities
 * Practical examples for managing dynamic configs
 */

/**
 * Create a new config from template
 */
export async function createConfigFromTemplate(templateName, outputPath) {
  const templates = {
    'strict': {
      version: '2.0.0',
      name: 'Strict Security Config',
      rules: {
        file: {
          maxFileSize: 5242880, // 5 MB
          blockedExtensions: ['.exe', '.dll', '.sh', '.bat', '.com']
        },
        security: {
          scanForSecrets: true
        }
      }
    },

    'permissive': {
      version: '2.0.0',
      name: 'Permissive Development Config',
      rules: {
        file: {
          maxFileSize: 104857600, // 100 MB
          blockedExtensions: []
        },
        security: {
          scanForSecrets: false
        }
      }
    }
  };

  const template = templates[templateName];
  if (!template) {
    throw new Error(`Unknown template: ${templateName}`);
  }

  const fs = await import('fs');
  const YAML = await import('yaml');

  const yaml = YAML.stringify(template);
  fs.writeFileSync(outputPath, yaml, 'utf8');
  console.log(`Created config from template: ${outputPath}`);
}

/**
 * Merge configs (useful for layered configuration)
 */
export function mergeConfigs(baseConfig, overrideConfig) {
  return {
    version: overrideConfig.version || baseConfig.version,
    name: overrideConfig.name || baseConfig.name,
    rules: {
      ...baseConfig.rules,
      ...overrideConfig.rules
    },
    policies: {
      ...baseConfig.policies,
      ...overrideConfig.policies
    }
  };
}

/**
 * Validate config has all required fields
 */
export function validateConfigStructure(config) {
  const errors = [];

  if (!config.version) errors.push('Missing version field');
  if (typeof config.rules !== 'object') errors.push('rules must be an object');
  if (config.policies && typeof config.policies !== 'object') {
    errors.push('policies must be an object');
  }

  return {
    valid: errors.length === 0,
    errors
  };
}

/**
 * Generate config documentation
 */
export function generateConfigDocs(configLoader) {
  const config = configLoader.getConfig();
  const docs = [];

  docs.push('# Configuration Reference\n');
  docs.push(`Version: ${config.version}`);
  docs.push(`Name: ${config.name}`);
  docs.push(`\n## Rules\n`);

  for (const [key, value] of Object.entries(config.rules || {})) {
    docs.push(`\n### ${key}`);
    docs.push('```');
    docs.push(JSON.stringify(value, null, 2));
    docs.push('```');
  }

  return docs.join('\n');
}

/**
 * Export config as different format
 */
export async function exportConfig(configLoader, format, outputPath) {
  const config = configLoader.getConfig();
  const fs = await import('fs');

  let content;
  if (format === 'json') {
    content = JSON.stringify(config, null, 2);
  } else if (format === 'yaml') {
    const YAML = await import('yaml');
    content = YAML.stringify(config);
  } else {
    throw new Error(`Unsupported format: ${format}`);
  }

  fs.writeFileSync(outputPath, content, 'utf8');
  console.log(`Exported config as ${format} to ${outputPath}`);
}

/**
 * Compare two configurations
 */
export function diffConfigs(config1, config2) {
  const diffs = [];

  const keys = new Set([
    ...Object.keys(config1.rules || {}),
    ...Object.keys(config2.rules || {})
  ]);

  for (const key of keys) {
    const val1 = config1.rules?.[key];
    const val2 = config2.rules?.[key];

    if (JSON.stringify(val1) !== JSON.stringify(val2)) {
      diffs.push({
        path: `rules.${key}`,
        from: val1,
        to: val2
      });
    }
  }

  return diffs;
}

These utilities let you manage configs programmatically: create from templates, merge, validate, document, and diff.

The Philosophy of Dynamic Configuration

Before we dig into environment-specific configs, let’s talk about why dynamic configuration matters philosophically. The fundamental insight is that rules change faster than code. Your security requirements shift. New threats emerge. Compliance regulations change. Your team learns what works and what doesn’t through experience.

If rules are hardcoded in your application, changes require code review, testing, deployment, and often a restart. That’s a weeks-long cycle for what should be a configuration change. Dynamic configuration decouples rules from deployment. Change a rule, and it takes effect immediately. New team members can make rule changes without touching code. Your ops team can respond to emerging threats without engineering involvement.

This matters because it changes your organizational structure. In a hardcoded-rules world, engineers own policy. In a dynamic-configuration world, policy ownership can be distributed. Your security team can configure security rules. Your finance team can configure cost controls. Your ops team can configure operational policies. Each group makes changes in their domain without waiting for engineers.

The tradeoff is that configuration mistakes can cascade. A bad rule deployed with hardcoded changes is caught in code review. A bad rule deployed dynamically goes live immediately. This is why validation and gradual rollout are so critical. Dynamic configuration is powerful, but it requires discipline.

The discipline pays off when you think about learning. Every incident your organization has teaches you something. An on-call engineer resolves a production incident and documents the fix. With dynamic configuration, that fix often involves changing a rule, not deploying new code. Rules evolve based on experience. Code stays mostly stable.

Over time, this creates a system where your organization’s accumulated knowledge is encoded in your configurations. New engineers joining the company don’t need to learn the rules through osmosis. They read the configuration files and see what rules are in place and why. Rule comments and version history document the reasoning.

Environment-Based Behavior

Here’s how to set up different configs per environment.

File: .claude/hook-config.development.yaml (Development)

version: "2.0.0"
name: "Claude Code Hook Rules - Development"

rules:
  file:
    maxFileSize: 104857600  # 100 MB
    blockedExtensions: []
    allowedDirectories: []
  quality:
    minTestCoverage: 0.00
    lintOnWrite: false
    formatOnWrite: false

File: .claude/hook-config.production.yaml (Production)

version: "2.0.0"
name: "Claude Code Hook Rules - Production"

rules:
  file:
    maxFileSize: 10485760  # 10 MB
    blockedExtensions: [.exe, .dll, .sh]
  quality:
    minTestCoverage: 0.75
    lintOnWrite: true
    formatOnWrite: true

File: load-config-by-env.mjs (Loader)




/**
 * Load config based on NODE_ENV
 */
export async function loadConfigForEnvironment(baseDir = '.claude') {
  const env = process.env.NODE_ENV || 'development';
  const configFile = path.join(baseDir, `hook-config.${env}.yaml`);

  const loader = new ConfigLoader(configFile);
  await loader.load();

  console.log(`[Config] Loaded ${env} configuration from ${configFile}`);
  return loader;
}

This pattern lets each environment have its own rules without code changes.

Secrets and Sensitive Data in Configuration

One concern that immediately comes up with configuration files is security. Your configuration files contain rules that might reference sensitive information—API keys, database URLs, credential locations. You don’t want these values floating around in plain text.

The typical solution is environment variables. Sensitive values are stored as environment variables, and your configuration file references them. The rule engine replaces references like ${DATABASE_URL} or ${AWS_ACCESS_KEY} with the actual values at runtime.

But this has tradeoffs. Environment variables work for simple scalar values, but not for complex structured data. A rule that says “connect to the database at this URL with these credentials” is easy to move to an environment variable. A rule that says “these 50 services are allowed to make outbound requests, but only to these 10 domains, with these rate limits” is harder to express in environment variables.

A better approach is configuration encryption. Store your configuration file on disk encrypted. The encryption key lives in an environment variable or comes from a key management service. When your hook loads the configuration, it decrypts it. Sensitive values are visible to the running code but not sitting in plain text on disk.

Another approach is configuration vaults. Your configuration file doesn’t contain sensitive values at all—it contains references to a vault. When the hook needs a sensitive value, it queries the vault. This adds latency but provides excellent security properties. Sensitive values never touch disk on the application server.

The approach you choose depends on your security requirements and operational complexity. For small teams with tight controls over who can access servers, environment variables and basic file permissions might be sufficient. For larger teams with compliance requirements, encryption or vault integration is worth the complexity.

Document your approach clearly. New team members shouldn’t guess how sensitive values are handled. They should read the docs and understand immediately.

Monitoring and Debugging

Let’s add introspection tools.

File: config-debug.mjs

/**
 * Configuration Debugging Utilities
 * Inspect and troubleshoot dynamic configurations
 */

export class ConfigDebugger {
  constructor(configLoader, ruleEngine) {
    this.configLoader = configLoader;
    this.ruleEngine = ruleEngine;
  }

  /**
   * Get detailed config info
   */
  getConfigInfo() {
    return {
      loadTime: this.configLoader.lastLoadTime,
      configHash: this.configLoader.configHash,
      version: this.configLoader.getConfig().version,
      environment: process.env.NODE_ENV || 'development',
      registeredRules: this.ruleEngine.getEvaluators().length
    };
  }

  /**
   * Trace rule evaluation step-by-step
   */
  traceRuleEvaluation(ruleName, context) {
    console.log(`\n[Trace] Evaluating rule: ${ruleName}`);
    console.log(`[Trace] Context: ${JSON.stringify(context, null, 2)}`);

    const result = this.ruleEngine.evaluateRule(ruleName, context);

    console.log(`[Trace] Result:`, result);
    return result;
  }

  /**
   * List all available rules
   */
  listRules() {
    return this.ruleEngine.getEvaluators();
  }

  /**
   * Get specific rule value
   */
  getRule(rulePath) {
    const parts = rulePath.split('.');
    let value = this.configLoader.getConfig().rules;

    for (const part of parts) {
      if (value && typeof value === 'object') {
        value = value[part];
      } else {
        return undefined;
      }
    }

    return value;
  }

  /**
   * Export debug report
   */
  generateDebugReport() {
    return {
      timestamp: new Date().toISOString(),
      config: this.getConfigInfo(),
      rules: this.listRules(),
      environment: {
        NODE_ENV: process.env.NODE_ENV,
        CLAUDE_CONFIG_PATH: process.env.CLAUDE_CONFIG_PATH
      }
    };
  }
}

These debug tools help troubleshoot configuration issues in production.

Testing Configuration Changes Before Deployment

One of the biggest advantages of dynamic configuration is the ability to test changes before they affect production. Implement a testing workflow that makes this easy.

Create a staging configuration file that mirrors your production configuration but with relaxed rules. Staging is where you experiment. You make a change to a rule and test it thoroughly before applying it to production. This is the same approach you’d use with code: test in staging, deploy to production.

Build a configuration diff tool that shows exactly what changed between versions. This is invaluable when you need to understand what impact a change will have. A rule that used to allow files up to 10 MB and now allows 50 MB—what will that affect? Your diff tool shows you the exact change and helps you think through the implications.

Implement a dry-run mode in your rule engine. Before actually enforcing a rule, you can simulate it against historical data. Run the rule engine in dry-run mode against the last week of operations. See how many would have been blocked or allowed with the new rules. This gives you confidence that your rule change does what you intend.

Another testing technique is gradual rollout. Instead of applying a new rule to everyone at once, apply it to a small percentage first. Monitor what happens. If all seems well, gradually increase the percentage. This is similar to canary deployments in continuous deployment—low risk, but you still discover issues before they affect everyone.

Document your testing approach. When someone wants to change a rule, they should know the expected process: change in staging, test thoroughly, dry-run against historical data, gradual rollout. This discipline prevents mistakes.

Real-World Pitfalls and How to Avoid Them

Before moving into operational lessons, let’s address the gotchas you’ll discover when you start using dynamic configuration in production. Knowing about these ahead of time prevents costly mistakes.

The first pitfall is configuration drift. You have a canonical configuration file that’s supposed to be the source of truth. But then someone updates the running configuration without updating the file. Or they update the file but the running instance doesn’t reload. Or they update it in staging but forget to update production. Suddenly, your canonical source isn’t really canonical—it’s just a suggestion.

Prevent this by making the configuration file immutable once loaded. Your system reads the file at startup. If someone wants to change configuration, they edit the file, and the system detects the change and reloads. This creates a single source of truth: the file. The file is always correct. Configuration drift becomes impossible because the running config is always derived from the file.

The second pitfall is version mismatch. You update your rule engine to support new rule types, but your configuration file is still using the old rule format. Or vice versa—you have old configuration format but new code. This breaks silently or partially, creating hard-to-debug issues.

Prevent this by versioning both your code and your configuration format. The configuration file starts with a version number. Your code checks this version number when loading. If there’s a mismatch, fail loudly rather than trying to make it work. This forces explicit migrations.

The third pitfall is permission and visibility issues. Your ops team updates a rule file they shouldn’t have access to. Someone reads a sensitive rule value that should be secret. Multiple people overwrite each other’s changes to the same rule file. Configuration management becomes chaotic.

Prevent this by treating configuration files like code. Store them in git with access controls. Use pull requests for changes. Require review before rules change. This seems heavyweight, but it prevents mistakes and creates an audit trail.

The fourth pitfall is performance degradation. You start with a simple configuration file. Over time, it grows to thousands of rules. Rule evaluation becomes slow. Claude’s requests start timing out while waiting for rule evaluation. Configuration that was a performance win becomes a bottleneck.

Prevent this by profiling rule evaluation regularly. If certain rules evaluate frequently, optimize them. If you have rules that rarely match, move them to a lower priority tier so they’re only evaluated if higher-priority rules pass. Use rule caching where rules produce the same output for the same inputs repeatedly.

The fifth pitfall is hidden dependencies. Rule A depends on the value of Rule B, but this dependency isn’t documented. You change Rule B, and suddenly Rule A behaves unexpectedly. Configuration becomes brittle.

Prevent this by documenting dependencies explicitly. If Rule A depends on Rule B, say so in comments. Better yet, use a rule engine that can detect and warn about dependencies. When you change a rule, your system should tell you which other rules depend on it.

Operational Lessons from the Field

Building dynamic configuration systems teaches you things that static configurations never will. Here’s what ops teams consistently discover after using dynamic hook configuration in production.

The first lesson is that configuration complexity grows quickly. You start with a few simple rules—max file size, blocked extensions. Six months in, you have rules about rate limiting, cost controls, security scanning, approval workflows, and environment-specific overrides. What started as a simple YAML file becomes a source of truth for how your entire organization uses Claude Code. Suddenly, configuration becomes critical infrastructure.

This means your configuration files deserve version control just like your code. Commit them to git. Review changes like you review code changes. Have a process for rolling back bad configs. Multiple people might update configs, and you need to understand what changed and why. A simple git log on your config files answers these questions instantly.

The second lesson is that config validation never catches everything. You can validate that all required fields exist and have the right types, but you can’t always validate whether the values make sense. A rule that says “minimum test coverage is 150%”? Technically valid, but obviously wrong. A rule that blocks all files? Also technically valid, but breaks everything. Validation catches syntax errors, not logic errors.

This is why gradual rollout matters. Test new configurations on a single developer’s machine first. Then in staging. Then in production for a subset of users. Only after you’ve seen the config work in real scenarios should you apply it globally. This catches logic errors before they affect everyone.

The third lesson is that configuration mistakes can cascade unexpectedly. You change one rule intending to affect tool X, but because of how your rule evaluation works, it affects tool Y too. This is why logging and observability in your rule engine matter so much. When a rule causes unexpected behavior, you need to be able to trace through the evaluation and understand exactly which rules matched and why.

Advanced Configuration Patterns

Once you have the basics working, there are patterns that make larger systems easier to manage.

Configuration inheritance lets you define base rules and then specialize them. Your base production config is strict: no executable files, small file size limits, security scanning enabled. Then different teams create specialized configs that inherit from the base and add or override specific rules for their use cases. The data team might increase file size limits for processing large datasets. The backend team might restrict certain file types but loosen directory restrictions.

Configuration composition lets you build complex rules from simpler building blocks. Instead of duplicating rule definitions, you reference them. Your staging config references the production config, then applies overrides. Your development config references staging, then applies development-specific changes. This creates a hierarchy where changes at the top cascade down, but children can still override specifics.

Feature flags in your configuration let you toggle rules on and off without changing the underlying values. You can deploy a new rule to production but keep it disabled until you’re ready. Then when the time comes, you flip a single boolean in the config file. No code deployment. No server restart. The rule is immediately active.

Rollback automation is another advanced pattern. Keep a history of configuration changes with timestamps. When something goes wrong after a configuration change, you don’t manually edit the config—you tell the system to rollback to the previous version. It pulls up the last known-good config and applies it automatically. Your team then has time to investigate what went wrong without the pressure of a broken system.

Configuration Testing and Validation

Quality gates apply to configurations too. Before applying a new configuration, you should verify it doesn’t break anything.

Schema validation is the first gate—we’ve covered that. The second gate is evaluation testing. Load your new configuration and run it against a test scenario. If you’re adding a new security rule, test it against sample code that should and shouldn’t pass. If you’re changing file size limits, test it against actual files you work with.

The third gate is impact analysis. Before applying a new configuration, simulate it against historical data. If you have logs of past operations, replay them against the new config and see how many would have been blocked or allowed. If a new config would have blocked 95% of past operations, that’s a problem. If it blocks 1%, maybe that’s acceptable.

The final gate is gradual application. Don’t apply new configs to everyone at once. Use feature flags or environment variables to apply them to a subset first. Monitor that subset for unexpected issues. Only after you’re confident should you enable globally.

Centralized vs. Distributed Configuration

As your organization grows, you might have multiple Claude Code instances running in different places—some on developer machines, some in CI/CD pipelines, some in production systems. The question becomes: where is configuration stored? How do they all stay in sync?

Centralized configuration works well for distributed systems. One central configuration server holds the source of truth. All Claude instances pull their configuration from this server at startup, and periodically refresh. Updates to the central config immediately become available to all instances. The tradeoff is that if the central server goes down, new instances can’t start, and existing instances can’t reload their config.

Distributed configuration mitigates this risk. Each instance has a local copy of the config file. They periodically sync with a central source, but continue working with their local copy if the sync fails. This is more resilient but harder to keep in sync. You might have instances with different configurations at any given time, which can cause confusion.

A hybrid approach works well in practice: central configuration for the canonical version, distributed copies in each instance’s filesystem for resilience. Instances try to sync periodically, but continue working with their local copy if the sync fails.

Key Takeaways

Dynamic hook configuration is powerful but requires discipline:

  1. Validate Everything – Schema validation catches errors before they cause problems

  2. Environment Separation – Different rules for dev/staging/prod prevent mistakes

  3. Hot Reload Safety – Verify new configs before applying them; rollback on failure

  4. Audit Trails – Track who changed what and when through version control

  5. Gradual Rollout – Test rule changes in staging before production

  6. Non-Developer Management – Ops teams should be able to update rules safely

  7. Clear Failure Modes – When enforcement fails, make it visible

  8. Observability – Log which rules matched and why

  9. Versioning – Keep configuration files in version control like code

  10. Testing – Verify configurations work as expected before deploying

The pattern we’ve built here—config loader, rule engine, watcher, environment overrides—works across any domain where you need runtime rule management. It’s not specific to Claude Code hooks. Database administrators use similar patterns for managing policies. Platform teams use them for feature flags. DevOps teams use them for infrastructure policies.

Your ops team updates a YAML file. Claude reloads it automatically. Rules change instantly. No deployment. No restart. No friction.

That’s the power of dynamic configuration. Once you’ve built this system, you understand how to manage runtime behavior without code deployments. The same patterns apply everywhere: separate values from logic, validate before applying, make rollback simple, and log everything for observability.

It’s not just about convenience. It’s about giving your entire organization the ability to respond to changing requirements without waiting for engineering. That’s transformative.

The Human Side of Configuration Management

Technical implementation is only half the story. The other half is organizational. When you introduce dynamic configuration, you’re redistributing power within your team. Ops people who previously had to wait for code deployments now have direct control over behavior. This is empowering but requires care.

Some engineers initially feel threatened. If anyone can change behavior through config files, what’s the point of code review? The answer is that different things deserve different governance. Code changes that affect behavior deserve review. Configuration changes that apply existing policies deserve less ceremony. The distinction is: are we changing how the system works fundamentally, or applying rules within the existing system?

The best organizations make this explicit. Code review is mandatory for architectural changes, new features, and major refactors. Configuration review is lightweight—maybe just a Slack approval from an ops lead—for changes that apply existing policies. This lets your organization move fast on configuration while maintaining rigor on code.

The other side is enabling non-technical people to manage configuration effectively. Your ops team might include people who understand systems deeply but aren’t software engineers. Configuration files for them should be as clear and accessible as possible. Extensive comments, clear structure, and good documentation matter more here than elegant abstractions.

Many teams invest in UI tools for managing configuration—web interfaces where ops people can change rules without editing YAML. This is valuable if your configuration complexity reaches a certain threshold. But for most teams, well-documented YAML files reviewed through git are sufficient. The key is making configuration management transparent and collaborative, not relegated to a small group of people who understand the system.

Why Hardcoded Rules Fail at Scale

To understand why dynamic configuration matters, it helps to understand why hardcoded rules fail. It usually happens gradually. Your first few rules are baked into the code. They work great. Then you need to change a rule for a migration—temporary override. You add an environment variable. Then another. Then another.

After six months, your code is full of environment variable checks. Your rule logic is scattered across multiple files. Different versions of your code running in different places have slightly different rules. Someone asks, “What rules are active in production right now?” and you don’t have a clean answer—you have to trace through code, environment variables, and git history to understand the current state.

At this point, configuration management becomes a source of anxiety. People are afraid to change things because they’re not sure what the impact will be. Someone inevitably makes a change that breaks something, but the break shows up days later, making it hard to correlate. Rules become documentation of what you thought you wanted, not what’s actually enforced.

Dynamic configuration solves this by making rules first-class. They’re not embedded in code or scattered across environment variables. They’re explicit, versioned, and easy to understand. Someone asking, “What rules are enforced in production?” gets a clear answer: read the config file.

The clarity alone is worth it. But the operational agility is the real win. When you can change behavior without redeploying code, you can respond to operational problems in minutes instead of hours. A security issue emerges? Update the config to block the attack vector immediately. A rule is causing false positives? Tweak it and rollout to staging. A customer needs special handling? Add an override rule. All without code deployment.

This operational agility compounds over time. Your team gets comfortable making small adjustments. They experiment with rules, see what works, and iterate. Over months, your rules become more sophisticated and better calibrated to reality. This evolution is only possible if changes are cheap. Dynamic configuration makes changes cheap.

Starting Your Dynamic Configuration Journey

If you’re convinced that dynamic configuration is valuable but unsure where to start, here’s a concrete path. Pick the simplest, most painful rule in your current system. The rule that changes most frequently. The rule that causes the most frustration when you need to update it.

Build a configuration file for just that one rule. Create a loader that reads it. Build a small rule engine that enforces it. Wire it into your hook system. Test it thoroughly. Get your team using it.

Once it works, add a second rule. Then a third. As your system grows, you’ll discover what works and what doesn’t. You’ll learn what abstractions matter and which ones are premature optimization.

After six months of adding rules incrementally, you’ll have a configuration system that works for your organization. It won’t be perfect—no system is. But it’ll be built on experience with your actual use cases, not theory.

That’s how you build configuration systems that last.


-iNet

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.