Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/

Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`.

While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, or a subagent whose type the platform doesn't report, also falls back to injecting.
While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, a pattern over 256 characters, or a subagent whose type the platform doesn't report, also falls back to injecting, with a warning on stderr.

Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/), [`.qoder/rules/`](.qoder/rules/)).

Expand Down
17 changes: 15 additions & 2 deletions hooks/ponytail-subagent.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,25 @@ function inject() {
}

// A bad regex must never crash the hook; treat it as "no matcher" and inject.
// The pattern is operator-controlled, so also cap it and warn on stderr
// (issue #658); a typo should be visible, not silently ignored.
const MAX_MATCHER_LEN = 256;
function warn(msg) {
// Stderr only; stdout is the hook payload and must stay valid JSON.
try { process.stderr.write('ponytail-subagent: ' + msg + '\n'); } catch (e) {}
}
let matcherRe = null;
try {
if (process.env.PONYTAIL_SUBAGENT_MATCHER) {
matcherRe = new RegExp(process.env.PONYTAIL_SUBAGENT_MATCHER, 'i');
const pattern = process.env.PONYTAIL_SUBAGENT_MATCHER;
if (pattern) {
if (pattern.length > MAX_MATCHER_LEN) {
warn('PONYTAIL_SUBAGENT_MATCHER exceeds ' + MAX_MATCHER_LEN + ' chars; ignoring');
} else {
matcherRe = new RegExp(pattern, 'i');
}
}
} catch (e) {
warn('PONYTAIL_SUBAGENT_MATCHER is invalid (' + (e && e.message) + '); ignoring');
matcherRe = null;
}

Expand Down
36 changes: 35 additions & 1 deletion tests/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,47 @@ assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);

// Invalid regex → must not crash; fall back to injecting everywhere.
// Invalid regex → must not crash; fall back to injecting everywhere, with a
// warning on stderr (issue #658).
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: '(' },
JSON.stringify({ agent_type: 'anything' }),
);
assert.equal(result.status, 0, result.stderr);
assert.ok(/PONYTAIL_SUBAGENT_MATCHER is invalid/.test(result.stderr), 'bad pattern must warn on stderr');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');

// Overlong pattern → rejected with a warning; falls back to injecting.
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'a'.repeat(300) },
JSON.stringify({ agent_type: 'anything' }),
);
assert.equal(result.status, 0, result.stderr);
assert.ok(/exceeds 256 chars/.test(result.stderr), 'overlong pattern must warn on stderr');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');

// The length cap is inclusive at 256 characters.
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'a'.repeat(256) },
JSON.stringify({ agent_type: 'a'.repeat(256) }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '', '256-char matcher is valid and must not warn');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');

result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'a'.repeat(257) },
JSON.stringify({ agent_type: 'a'.repeat(257) }),
);
assert.equal(result.status, 0, result.stderr);
assert.ok(/exceeds 256 chars/.test(result.stderr), '257-char matcher must warn');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');

Expand Down