mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 23:49:01 +02:00
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:
co-authored by
Claude Fable 5
parent
5486cd6620
commit
9c7b0a2c95
@@ -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' }] } },
|
||||
|
||||
@@ -82,6 +82,12 @@ function runHook(stdin: object, cwd?: string, extraEnv?: Record<string, string>)
|
||||
// via extraEnv.
|
||||
delete env.CONDUCTOR_WORKSPACE_PATH;
|
||||
delete env.CONDUCTOR_PORT;
|
||||
// Same reasoning for the spawned markers (#2733): running the suite inside
|
||||
// an OpenClaw/spawned-marked session would flip the [conductor] prose deny
|
||||
// into the [conductor][spawned] auto-choose deny. Spawned cases opt back in
|
||||
// explicitly via extraEnv.
|
||||
delete env.OPENCLAW_SESSION;
|
||||
delete env.GSTACK_SESSION_KIND;
|
||||
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
||||
if (extraEnv) Object.assign(env, extraEnv);
|
||||
const res = spawnSync(HOOK, [], {
|
||||
@@ -527,6 +533,72 @@ describe('Conductor prose redirect', () => {
|
||||
);
|
||||
expectPassThrough(r);
|
||||
});
|
||||
|
||||
test('prose deny carries the spawned-subagent escape sentence (#2733)', () => {
|
||||
// A per-command env prefix in a subagent's bash can never reach this hook
|
||||
// (hooks inherit the harness env), so the deny TEXT must carry the escape
|
||||
// hatch — otherwise a marked subagent that slips and calls AUQ is
|
||||
// instructed to prose-STOP, recreating the bug through the hook layer.
|
||||
const r = runHook({
|
||||
session_id: 'c7',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-c7',
|
||||
tool_input: {
|
||||
questions: [
|
||||
{ question: '<gstack-qid:test-q> Need approval?', options: ['A) Yes (recommended)', 'B) No'] },
|
||||
],
|
||||
},
|
||||
}, undefined, CONDUCTOR);
|
||||
const reason = r.parsed?.hookSpecificOutput?.permissionDecisionReason ?? '';
|
||||
expect(reason).toMatch(/spawned subagent[\s\S]*auto-choose the recommended option/i);
|
||||
// Destructive exclusion rides the same sentence (unified semantics).
|
||||
expect(reason).toMatch(/destructive or irreversible gate[\s\S]*conservative/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Conductor + env-detected spawned: auto-choose deny, not prose (#2733)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
describe('Conductor spawned deny (#2733)', () => {
|
||||
const Q = {
|
||||
questions: [
|
||||
{ question: '<gstack-qid:test-q> Bump VERSION?', options: ['A) Skip (recommended)', 'B) Bump'] },
|
||||
],
|
||||
};
|
||||
|
||||
test('Conductor + OPENCLAW_SESSION → [conductor][spawned] auto-choose deny, not prose', () => {
|
||||
const r = runHook(
|
||||
{ session_id: 's1', tool_name: 'AskUserQuestion', tool_use_id: 'tu-s1', tool_input: Q },
|
||||
undefined,
|
||||
{ CONDUCTOR_PORT: '55070', OPENCLAW_SESSION: '1' },
|
||||
);
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const reason = r.parsed?.hookSpecificOutput?.permissionDecisionReason ?? '';
|
||||
expect(reason).toContain('[conductor][spawned]');
|
||||
expect(reason).toMatch(/auto-choose the recommended option/i);
|
||||
expect(reason).not.toMatch(/reply with a letter/i);
|
||||
});
|
||||
|
||||
test('Conductor + GSTACK_SESSION_KIND=spawned env → same auto-choose deny', () => {
|
||||
const r = runHook(
|
||||
{ session_id: 's2', tool_name: 'AskUserQuestion', tool_use_id: 'tu-s2', tool_input: Q },
|
||||
undefined,
|
||||
{ CONDUCTOR_WORKSPACE_PATH: '/Users/x/conductor/ws', GSTACK_SESSION_KIND: 'spawned' },
|
||||
);
|
||||
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
||||
const reason = r.parsed?.hookSpecificOutput?.permissionDecisionReason ?? '';
|
||||
expect(reason).toContain('[conductor][spawned]');
|
||||
expect(reason).toMatch(/never auto-approve a destructive or irreversible option/i);
|
||||
});
|
||||
|
||||
test('both hooks source their spawned directive from the shared constant (drift guard)', () => {
|
||||
const hooksDir = path.join(ROOT, 'hosts', 'claude', 'hooks');
|
||||
for (const f of ['question-preference-hook.ts', 'auq-error-fallback-hook.ts']) {
|
||||
const src = fs.readFileSync(path.join(hooksDir, f), 'utf-8');
|
||||
expect(src, `${f} must import the shared spawned directive`).toContain("from './spawned-directive'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user