nanocoai/nanoclaw · critical

${settingsFile} hooks.SessionStart must be an array

Error message

${settingsFile} hooks.SessionStart must be an array

What it means

lintSecrets scans MCP server config values and throws when a value matches a real-credential pattern (SECRET_VALUE_RE) instead of the literal placeholder. An auth-scheme prefix like 'Bearer ' is stripped before matching, so 'Bearer sk-...' still trips it. Templates must ship the placeholder; operators inject real secrets after stamping.

Source

Thrown at container/agent-runner/src/providers/claude.ts:376

}

function claudeConfigDir(): string {
  return process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || os.homedir(), '.claude');
}

function writeMemorySessionHook(hook: MemorySessionHookRegistration): void {
  const configDir = claudeConfigDir();
  const settingsFile = path.join(configDir, 'settings.json');
  fs.mkdirSync(configDir, { recursive: true });

  const parsed: unknown = fs.existsSync(settingsFile) ? JSON.parse(fs.readFileSync(settingsFile, 'utf-8')) : {};
  if (!isRecord(parsed)) throw new Error(`${settingsFile} must contain a JSON object`);

  const hooks = parsed.hooks === undefined ? {} : parsed.hooks;
  if (!isRecord(hooks)) throw new Error(`${settingsFile} hooks must be a JSON object`);

  const sessionStart = hooks.SessionStart === undefined ? [] : hooks.SessionStart;
  if (!Array.isArray(sessionStart)) throw new Error(`${settingsFile} hooks.SessionStart must be an array`);

  const memoryCommands = new Set([hook.command, ...hook.legacyCommands]);
  const nextSessionStart = sessionStart
    .map((entry) => removeMemoryCommands(entry, memoryCommands))
    .filter((entry) => entry !== undefined);
  nextSessionStart.push({
    matcher: hook.sources.join('|'),
    hooks: [{ type: 'command', command: hook.command, timeout: 10 }],
  });

  hooks.SessionStart = nextSessionStart;
  parsed.hooks = hooks;
  fs.writeFileSync(settingsFile, JSON.stringify(parsed, null, 2) + '\n');
}

function removeMemoryCommands(value: unknown, commands: ReadonlySet<string>): unknown {
  if (!isRecord(value) || !Array.isArray(value.hooks)) return value;
  const hooks = value.hooks.filter((hook) => {

View on GitHub (pinned to 294ef2aee8)

Solutions

  1. Replace the real credential with the literal placeholder value (see PLACEHOLDER_VALUE in mcp.ts)
  2. Move the real secret to your local/operator config, outside the template
  3. If the match is a false positive, rename or restructure the value so it doesn't match SECRET_VALUE_RE

Example fix

// before
{ "headers": { "Authorization": "Bearer sk-live-abc123" } }
// after
{ "headers": { "Authorization": "{{SECRET}}" } }  // use the exact PLACEHOLDER_VALUE literal
Defensive patterns

Strategy: validation

Validate before calling

for (const [key, value] of Object.entries(configValues)) {
  const bare = String(value).replace(/^(Bearer|Token|Basic)\s+/i, '');
  if (value !== PLACEHOLDER_VALUE && SECRET_VALUE_RE.test(bare)) {
    throw new Error(`refusing to ship real credential in ${key}`);
  }
}

Type guard

function isSafeTemplateValue(value: string): boolean {
  return value === PLACEHOLDER_VALUE || !SECRET_VALUE_RE.test(value.replace(/^(Bearer|Token|Basic)\s+/i, ''));
}

Try / catch

try { readServerEntry(...); } catch (e) { if (e instanceof Error && e.message.includes('looks like a real credential')) { /* scrub value to PLACEHOLDER_VALUE, fail the publish */ } else throw e; }

Prevention

When it happens

Trigger: A server entry value (env/header/url param) containing something that looks like an API key or token (e.g. 'sk-...', 'ghp_...', 'Bearer sk-...') rather than the PLACEHOLDER_VALUE literal.

Common situations: Developers pasting a working local config with real keys into a template they intend to commit; testing with real credentials and forgetting to scrub before publishing.

Related errors


AI-assisted analysis of nanocoai/nanoclaw@294ef2aee8 (2026-08-28). Data as JSON: /api/errors/4e9370558cbca553. Report an issue: GitHub.