mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 00:49:00 +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
@@ -32,6 +32,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { runBin } from './spawn-bin';
|
||||
import { SPAWNED_ESCAPE_SENTENCE } from './spawned-directive';
|
||||
|
||||
interface HookStdin {
|
||||
tool_name?: string;
|
||||
@@ -158,12 +159,17 @@ export function directiveFor(kind: 'spawned' | 'headless' | 'interactive'): stri
|
||||
);
|
||||
case 'interactive':
|
||||
default:
|
||||
// #2733: the shell-out above runs in the HARNESS env, so a subagent
|
||||
// marked spawned via a per-command env prefix still classifies as
|
||||
// interactive here — the escape sentence is the only lever for that
|
||||
// topology (see spawned-directive.ts).
|
||||
return (
|
||||
lead +
|
||||
'SESSION_KIND=interactive — render the decision as a PROSE message now: a clear ELI10 of the issue, ' +
|
||||
'then a Recommendation line, then ONE paragraph per choice carrying its `(recommended)` marker, its ' +
|
||||
'`Completeness: X/10`, and 2-4 sentences of reasoning. Tell the user to reply with a letter, then STOP. ' +
|
||||
'(Retry the call once first only if no answer could have surfaced.)'
|
||||
'(Retry the call once first only if no answer could have surfaced.) ' +
|
||||
SPAWNED_ESCAPE_SENTENCE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import * as os from 'os';
|
||||
import { runBin, repoRoot } from './spawn-bin';
|
||||
import { isConductor } from '../../../lib/is-conductor';
|
||||
import { classifyQuestion } from '../../../scripts/one-way-doors';
|
||||
import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv } from './spawned-directive';
|
||||
|
||||
interface HookStdin {
|
||||
session_id?: string;
|
||||
@@ -484,6 +485,17 @@ async function main(): Promise<void> {
|
||||
// preference, or door type — including one-way doors, which must reach the
|
||||
// human via prose rather than the unreliable tool.
|
||||
if (isConductor()) {
|
||||
// #2733: env-level spawned sessions (OpenClaw inside a Conductor
|
||||
// workspace, or a harness launched with GSTACK_SESSION_KIND=spawned in
|
||||
// its env) get an auto-choose deny — a prose brief has no reader there.
|
||||
// LIMITATION: a per-command GSTACK_SESSION_KIND prefix inside a
|
||||
// subagent's bash never reaches this hook (hooks inherit the harness
|
||||
// env); that case is covered by the escape sentence below plus the
|
||||
// dispatching skill's prompt.
|
||||
if (spawnedByEnv()) {
|
||||
deny(CONDUCTOR_SPAWNED_DENY_REASON + (memoryContext ? `\n${memoryContext}` : ''));
|
||||
return;
|
||||
}
|
||||
const conductorReason =
|
||||
'[conductor] AskUserQuestion is unreliable in Conductor (native disabled, MCP variant flaky). ' +
|
||||
'Do NOT call AskUserQuestion (native or any mcp__*__AskUserQuestion). Render this decision as a ' +
|
||||
@@ -491,7 +503,8 @@ async function main(): Promise<void> {
|
||||
'paragraph per choice carrying its `(recommended)` marker and `Completeness: X/10`; tell the user ' +
|
||||
'to reply with a letter, then STOP. For a one-way/destructive confirmation, require an explicit ' +
|
||||
'typed confirmation and do NOT proceed on a vague reply. Capture the decision with gstack-question-log ' +
|
||||
'(PostToolUse will not fire on a prose path).' +
|
||||
'(PostToolUse will not fire on a prose path). ' +
|
||||
SPAWNED_ESCAPE_SENTENCE +
|
||||
(memoryContext ? `\n${memoryContext}` : '');
|
||||
deny(conductorReason);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Shared spawned-session directive text for the AUQ hooks (#2733).
|
||||
*
|
||||
* Hook processes inherit the HARNESS env, so a per-command
|
||||
* `GSTACK_SESSION_KIND=spawned` prefix inside a subagent's bash call can
|
||||
* never reach a hook — the only levers a hook has for the subagent case are
|
||||
* (a) env-level markers that ARE session-wide (OPENCLAW_SESSION, or a harness
|
||||
* launched with GSTACK_SESSION_KIND in its env) and (b) directive TEXT the
|
||||
* model reads. Both AUQ hooks (question-preference PreToolUse deny,
|
||||
* auq-error-fallback PostToolUse directive) carry the same escape sentence;
|
||||
* it lives here as one constant so the two paths can never drift into
|
||||
* contradictory instructions.
|
||||
*
|
||||
* Destructive semantics are unified across every spawned surface (dispatch
|
||||
* prompt, spawned-session block, AUQ prose rule, both hooks):
|
||||
* conservative-continue, never prose-STOP — a prose brief with no reader is
|
||||
* always wrong in a spawned session, and the conservative choice guarantees
|
||||
* nothing irreversible happens.
|
||||
*/
|
||||
|
||||
/** Appended to prose-directing hook texts so a marked subagent that slips
|
||||
* and calls AUQ still resolves to auto-choose instead of prose-STOP. */
|
||||
export const SPAWNED_ESCAPE_SENTENCE =
|
||||
'If this session was spawned by an orchestrator or a parent agent and no human reads its ' +
|
||||
'output mid-run (e.g. your dispatch prompt says you are a spawned subagent), do not render ' +
|
||||
'the prose brief either — auto-choose the recommended option and continue; at a destructive ' +
|
||||
'or irreversible gate, do not execute the destructive action: take the conservative ' +
|
||||
'non-destructive choice (skip/defer), record it, and continue.';
|
||||
|
||||
/** Deterministic deny reason for env-detected spawned sessions inside Conductor. */
|
||||
export const CONDUCTOR_SPAWNED_DENY_REASON =
|
||||
'[conductor][spawned] AskUserQuestion is unreliable in Conductor and this session is ' +
|
||||
'orchestrator-spawned — no human reads its output. Do NOT retry the tool and do NOT render ' +
|
||||
'a prose decision brief: auto-choose the recommended option for each question above, note ' +
|
||||
'the choice, and continue the workflow. Exception: never auto-approve a destructive or ' +
|
||||
'irreversible option — take the conservative non-destructive choice (skip/defer), note it, ' +
|
||||
'and continue.';
|
||||
|
||||
/**
|
||||
* Env-level spawned detection (direct env read — PreToolUse hot path, no
|
||||
* shell-out). Mirrors bin/gstack-session-kind steps 0-1. True only for
|
||||
* session-wide markers; a per-command prefix in subagent bash is invisible
|
||||
* here by construction.
|
||||
*/
|
||||
export function spawnedByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!env.OPENCLAW_SESSION || env.GSTACK_SESSION_KIND === 'spawned';
|
||||
}
|
||||
Reference in New Issue
Block a user