fix(hooks): spawned-session escape in Conductor AUQ deny; override coverage in AUQ-error fallback (#2733)

Hooks inherit the harness env, so a per-command GSTACK_SESSION_KIND
prefix inside a subagent's bash can never reach them. Levers added:
a deterministic [conductor][spawned] auto-choose deny for env-level
spawned sessions (OPENCLAW_SESSION or session-wide GSTACK_SESSION_KIND),
and a spawned escape sentence appended to both hooks' prose directives
so a marked subagent that slips and calls AUQ resolves to auto-choose
instead of prose-STOP. The sentence lives in one shared constant
(hosts/claude/hooks/spawned-directive.ts) so the two paths can never
drift; destructive semantics are unified to conservative-continue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-30 23:42:38 +00:00
co-authored by Claude Fable 5
parent 5486cd6620
commit 9c7b0a2c95
5 changed files with 158 additions and 2 deletions
+18
View File
@@ -74,6 +74,15 @@ describe('directiveFor — per-session-kind instruction', () => {
test('spawned directive auto-chooses', () => {
expect(directiveFor('spawned')).toMatch(/auto-choose/i);
});
test('interactive directive carries the spawned escape sentence (#2733)', () => {
// The sessionKind() shell-out runs in the HARNESS env, so a subagent
// marked spawned via a per-command prefix classifies interactive here —
// the directive text is the only lever for that topology.
const d = directiveFor('interactive');
expect(d).toMatch(/spawned subagent[\s\S]*auto-choose the recommended option/i);
expect(d).toMatch(/destructive or irreversible gate[\s\S]*conservative/i);
});
});
/** Spawn the hook with synthetic stdin + controlled env; parse its JSON stdout. */
@@ -113,6 +122,15 @@ describe('hook integration — invoked as PostToolUse', () => {
expect(out.additionalContext).toMatch(/auto-choose/i);
});
test('error result + GSTACK_SESSION_KIND=spawned env → override beats Conductor-interactive (#2733)', () => {
const out = runHook(
{ tool_name: 'AskUserQuestion', tool_response: { is_error: true } },
{ GSTACK_SESSION_KIND: 'spawned', CONDUCTOR_PORT: '55010' },
);
expect(out.additionalContext).toMatch(/SESSION_KIND=spawned/);
expect(out.additionalContext).toMatch(/auto-choose/i);
});
test('SUCCESSFUL answer → no injection (inert on real answers)', () => {
const out = runHook(
{ tool_name: 'AskUserQuestion', tool_response: { answers: [{ option_label: 'A' }] } },