`subagentStart` Hook Deny Is Not Enforced

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

A subagentStart hook returns permission: “deny” and exits with code 2 for disallowed models, but Cursor still starts or retries the blocked subagent.

The hook log confirms that models such as gpt-5.6-sol-max were denied, yet the subagent continued running.

Screenshot

the hooks log is miss because the limit of record is limited

Steps to Reproduce

  1. Configure a subagentStart hook with failClosed: true.
  2. Return permission: “deny” and exit code 2 for a specific model.
  3. Start a task that creates a subagent using that model.
  4. Observe that Cursor reports the denial, UI for subagent is Couldn't Show, but the subagent still running in the background.

Expected Behavior

block the subagent if the model is not allow

Operating System

Windows 10/11

Version Information

Version: 3.12.17 (user setup)
VS Code Extension API: 1.128.0
Commit: 0fb762053c34788bb7760d5673f8a6d4c8589d50
Date: 2026-07-17T02:53:53.006Z
Layout: Agent Window
Build Type: Stable
Release Track: Nightly
Electron: 40.10.3
Chromium: 144.0.7559.236
Node.js: 24.15.0
V8: 14.4.258.32-electron.0
xterm.js: 6.1.0-beta.256
OS: Windows_NT x64 10.0.26200

For AI issues: which model did you use?

Grok 4.5

For AI issues: add Request ID with privacy disabled

Request ID: 906e945e-b976-4147-b57b-411ab67b5c31
fixed after i add rules and prompt to use grok 4.5 explicitly.

Additional Information

I cannot upload the code directly, i paste here:

#~/.cursor/hooks/deny-non-allowlisted-subagent-model.js

#!/usr/bin/env node
// subagentStart / preToolUse(Task) — allow only Composer or Grok 4.5.
// Deny: permission:"deny" + exit code 2. stdout via writeSync (Windows flush).
// https://forum.cursor.com/t/subagent-model-choice-not-respected/163645

'use strict';

const fs = require('fs');
const path = require('path');
const {
  decideSubagentStart,
  parseHookInput,
} = require('./deny-non-allowlisted-subagent-model-lib.js');

const DEBUG_LOG = path.join(__dirname, 'subagent-model-allowlist.log');

function appendDebug(entry) {
  try {
    fs.appendFileSync(DEBUG_LOG, `${JSON.stringify(entry)}\n`);
  } catch {
    // ignore
  }
}

function respond(payload, exitCode) {
  fs.writeSync(1, JSON.stringify(payload));
  process.exit(exitCode);
}

let raw = '';
const stdinTimeout = setTimeout(() => {
  respond(
    {
      permission: 'deny',
      user_message:
        'Subagent blocked: timed out reading hook payload (model unknown).',
    },
    2
  );
}, 10000);

process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  raw += chunk;
});
process.stdin.on('end', () => {
  clearTimeout(stdinTimeout);
  try {
    const input = parseHookInput(raw);
    const decision = decideSubagentStart(input);
    appendDebug({
      ts: new Date().toISOString(),
      hook_event_name: input.hook_event_name,
      tool_name: input.tool_name,
      subagent_type: input.subagent_type,
      subagent_model: input.subagent_model,
      model: input.model,
      tool_input_model: input.tool_input?.model,
      decision: decision.permission,
    });
    respond(decision, decision.permission === 'deny' ? 2 : 0);
  } catch (err) {
    appendDebug({
      ts: new Date().toISOString(),
      error: String(err && err.message ? err.message : err),
      raw_preview: String(raw).slice(0, 2000),
      decision: 'deny',
    });
    respond(
      {
        permission: 'deny',
        user_message:
          'Subagent blocked: invalid hook payload (could not read model).',
      },
      2
    );
  }
});
# ~/.cursor/hooks/deny-non-allowlisted-subagent-model-lib.js
'use strict';

const ALLOW_COMPOSER = /composer/i;
const ALLOW_GROK_45 = /grok[-.]?4\.?5/i;

function isAllowedModel(model) {
  if (typeof model !== 'string') return false;
  const trimmed = model.trim();
  if (!trimmed) return false;
  return ALLOW_COMPOSER.test(trimmed) || ALLOW_GROK_45.test(trimmed);
}

function coerceToolInput(toolInput) {
  if (typeof toolInput === 'string') {
    try {
      return JSON.parse(toolInput);
    } catch {
      return {};
    }
  }
  if (toolInput && typeof toolInput === 'object') return toolInput;
  return {};
}

function parseHookInput(raw) {
  const text = String(raw ?? '')
    .replace(/^\uFEFF/, '')
    .trim();
  if (!text) return {};
  const input = JSON.parse(text);
  if (input && typeof input === 'object' && 'tool_input' in input) {
    return { ...input, tool_input: coerceToolInput(input.tool_input) };
  }
  return input;
}

function extractModel(input) {
  if (!input || typeof input !== 'object') return '';

  const toolInput = coerceToolInput(input.tool_input ?? input.input);
  const isTask =
    String(input.tool_name || input.tool || '').toLowerCase() === 'task';

  // preToolUse(Task): top-level `model` is the PARENT agent — ignore it.
  const candidates = isTask
    ? [toolInput.model, toolInput.subagent_model, input.subagent_model]
    : [
        input.subagent_model,
        toolInput.model,
        toolInput.subagent_model,
        input.model,
      ];

  for (const value of candidates) {
    if (typeof value === 'string' && value.trim()) return value.trim();
  }
  return '';
}

function decideSubagentStart(input) {
  const model = extractModel(input);
  // Missing/empty = auto (inherit parent/runtime). Preference order is agent rule:
  // Grok 4.5 → Composer → omit.
  if (!model) {
    return { permission: 'allow' };
  }
  if (isAllowedModel(model)) {
    return { permission: 'allow' };
  }
  return {
    permission: 'deny',
    user_message:
      `Subagent blocked: model "${model}" is not on the allowlist (composer* or grok-4.5*). ` +
      'Retry with Composer or Grok 4.5, omit model for auto, or do the work in the parent agent.',
    agent_message:
      `Task/subagent denied by allowlist hook. Requested model: ${model}. ` +
      'Only composer* or grok-4.5* are allowed (or omit model for auto). Do the work in the parent agent instead of retrying with a blocked model.',
  };
}
module.exports = {
  isAllowedModel,
  extractModel,
  parseHookInput,
  decideSubagentStart,
};
``

### Does this stop you from using Cursor
No - Cursor works, but with this issue

hooks.json:

{
  "version": 1,
  "hooks": {
    // ...
    "subagentStart": [
      {
        "type": "command",
        "command": "\"C:/Program Files/nodejs/node.exe\" \"C:/Users/.../.cursor/hooks/deny-non-allowlisted-subagent-model.js\"",
        "failClosed": true
      }
    ],
    //...
  }
}

also seem this code block is keep rerender btw, i cannot select the language which the list will close right after click

Thanks for reporting this, @cy_c! I was able to reproduce the issue and have filed a bug with the team.

In the meantime, you may want to try a different hook type, such as preToolUse, and read tool_input.model when making decisions about allowing/denying the tool!