Skip to content

Commit 66e52c0

Browse files
fix(hooks): decouple secretary-spawn carve-out to a members-only join witness (#1023)
PR #1021's config-less inbox-witness fallback in _team_has_secretary was also consumed by the bootstrap_gate secretary-spawn carve-out (binding 5). The inbox created by TaskUpdate(owner=secretary) BEFORE the Agent spawn made the witness True prematurely, so the carve-out DENIED the canonical secretary spawn — re-deadlocking bootstrap on every fresh CLI session. Adds a members[]-only JOIN witness (_secretary_in_members) in bootstrap_gate with a broad except for the get_claude_config_dir/Path.home RuntimeError seam; the local marker_writer import is removed and _team_has_secretary keeps its inbox DISPATCH witness for the marker writer. A witness-read error now fires the carve-out (allow) — safe, since it only ever permits the canonical secretary spawn (bindings 1-3 gate all else). Non-vacuous both-modes regression (real task-assignment inbox), symbol-name docstring cleanup, PATCH 4.4.40. Closes #1023.
1 parent e371ac9 commit 66e52c0

11 files changed

Lines changed: 857 additions & 295 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "PACT",
1313
"source": "./pact-plugin",
1414
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
15-
"version": "4.4.39",
15+
"version": "4.4.40",
1616
"author": {
1717
"name": "Synaptic-Labs-AI"
1818
},

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
605605
│ └── cache/
606606
│ └── pact-plugin/
607607
│ └── PACT/
608-
│ └── 4.4.39/ # Plugin version
608+
│ └── 4.4.40/ # Plugin version
609609
│ ├── agents/
610610
│ ├── commands/
611611
│ ├── skills/

pact-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "PACT",
3-
"version": "4.4.39",
3+
"version": "4.4.40",
44
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
55
"author": {
66
"name": "Synaptic-Labs-AI",

pact-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PACT — Orchestration Harness for Claude Code
22

3-
> **Version**: 4.4.39
3+
> **Version**: 4.4.40
44
55
Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.
66

pact-plugin/hooks/bootstrap_gate.py

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -361,10 +361,10 @@ def _degraded_decision(stage: str, error: BaseException, tool_name: "str | None"
361361
# carve-out below; any drift silently re-introduces the bootstrap-deadlock
362362
# these constants are here to prevent.
363363
#
364-
# _SECRETARY_NAME mirrors bootstrap_marker_writer._SECRETARY_NAME (the
365-
# producer-side constant at marker_writer.py:103) and the literal at
366-
# commands/bootstrap.md Step 2. Cross-file atomic edits required across
367-
# this file, bootstrap_marker_writer.py, AND commands/bootstrap.md.
364+
# _SECRETARY_NAME mirrors the producer-side bootstrap_marker_writer._SECRETARY_NAME
365+
# constant and the literal at commands/bootstrap.md Step 2. Cross-file atomic
366+
# edits required across this file, bootstrap_marker_writer.py, AND
367+
# commands/bootstrap.md.
368368
#
369369
# _SECRETARY_AGENT_TYPE is the canonical agentType from
370370
# commands/bootstrap.md Step 2 — no producer-side mirror in
@@ -386,6 +386,47 @@ def _degraded_decision(stage: str, error: BaseException, tool_name: "str | None"
386386
)
387387

388388

389+
def _secretary_in_members(team_name: str) -> bool:
390+
"""Members[]-only JOIN witness: has the secretary actually joined the team
391+
roster? Sole consumer is _is_canonical_secretary_spawn binding 5.
392+
393+
Reads the team config members[] via the shared pact_context._iter_members
394+
helper (already imported at this module's top level) and returns True iff a
395+
member's ``name`` equals _SECRETARY_NAME. This is a JOIN witness — distinct
396+
from bootstrap_marker_writer._team_has_secretary, which is a DISPATCH
397+
witness (members[] OR the secretary's inbox file). The two predicates
398+
answer DIFFERENT questions on purpose (#1023): the marker writer needs
399+
"was the secretary DISPATCHED?" (the inbox is created by
400+
TaskUpdate(owner=secretary) BEFORE the spawn, so a dispatch witness is
401+
correct there); the carve-out needs "has the secretary JOINED members[]?"
402+
(only an actual Agent() spawn-return populates members[]). Reading the
403+
inbox arm here is what re-deadlocked the canonical secretary spawn in
404+
#1021 — the inbox predates the spawn, so binding 5 (`not True`) denied the
405+
very spawn the carve-out exists to permit.
406+
407+
NEVER raises (totality, #989). The body is wrapped in a BROAD
408+
``except Exception: return False`` rather than a typed tuple because the
409+
members[] read transitively calls pact_context.get_claude_config_dir() ->
410+
Path.home(), which raises RuntimeError when HOME is unresolvable. That
411+
RuntimeError is composed BEFORE _iter_members' own typed try-block, so it
412+
escapes _iter_members uncaught, and it is ALSO absent from binding 5's
413+
outer typed except — without a broad wrap here it would propagate to
414+
main()'s degraded-DENY path and re-deadlock the secretary spawn (the wrong
415+
fail direction). A False return makes the carve-out FIRE (the safe
416+
direction — it only ever permits the canonical secretary spawn, never a
417+
non-secretary tool, which bindings 1/2/3 already exclude). This mirrors the
418+
bare-except seam precedent at shared.pact_context._resolve_aligned_team_name,
419+
which uses a broad except for the same Path.home RuntimeError reason.
420+
"""
421+
try:
422+
return any(
423+
member.get("name") == _SECRETARY_NAME
424+
for member in pact_context._iter_members(team_name)
425+
)
426+
except Exception: # noqa: BLE001 — broad by design: Path.home() RuntimeError seam (see docstring)
427+
return False
428+
429+
389430
def _is_canonical_secretary_spawn(input_data: dict) -> bool:
390431
"""Audit anchor: canonical secretary spawn carve-out for #789.
391432
@@ -404,30 +445,62 @@ def _is_canonical_secretary_spawn(input_data: dict) -> bool:
404445
(the orchestrator may still pass a stale arg the platform discards).
405446
The carve-out stays tight via bindings 2/3 (exact subagent_type +
406447
name literals) and binding 5 (one-shot, gated on the REAL team dir).
407-
5. NOT _team_has_secretary(get_team_name()) — one-shot semantic; flips
408-
to False the moment the spawned secretary lands in members[]. Reads
409-
the REAL session team dir (expected_team), which the empty-team
410-
fail-closed below guarantees is a non-empty path segment.
448+
5. NOT _secretary_in_members(get_team_name()) — members[]-only JOIN
449+
witness (#1023). Reads the REAL session team dir (expected_team),
450+
which the empty-team fail-closed below guarantees is a non-empty path
451+
segment. Uses the gate-local _secretary_in_members helper (a JOIN
452+
witness), NOT bootstrap_marker_writer._team_has_secretary (a DISPATCH
453+
witness that also accepts the secretary's inbox file). See the
454+
join-vs-dispatch note below.
411455
412456
Binding (1) is a hardcoded literal. Bindings (2) and (3) compare against
413457
module constants, not tool_input-derived values. Binding (5) is a disk
414-
read of the team config members[]; True after first successful dispatch,
415-
so the carve-out fires at most once per session. With binding 4 dropped,
416-
the carve-out reads no tool_input-derived team value — the
417-
secretary-presence check resolves against the SSOT team dir only.
458+
read of the team config members[]. With binding 4 dropped, the carve-out
459+
reads no tool_input-derived team value — the secretary-presence check
460+
resolves against the SSOT team dir only.
461+
462+
JOIN witness vs DISPATCH witness (#1023): binding 5 calls the gate-local
463+
_secretary_in_members (members[]-ONLY) and deliberately does NOT call
464+
bootstrap_marker_writer._team_has_secretary. The two answer DIFFERENT
465+
questions. The marker writer needs "was the secretary DISPATCHED?" and so
466+
accepts the inbox file as a fallback witness — correct for it, because the
467+
inbox is the config-less Desktop signal (#1019). But the inbox is created
468+
by TaskUpdate(owner=secretary) (bootstrap Step 2) BEFORE the Agent spawn
469+
(Step 3), so reading the inbox here made binding 5 (`not True`) DENY the
470+
very spawn the carve-out exists to permit — the #1021 regression. The
471+
carve-out needs "has the secretary JOINED members[]?", which only an actual
472+
Agent() spawn-return populates. Hence the split: members[]-only here, inbox
473+
fallback left to the marker writer alone.
474+
475+
ONE-SHOT GUARANTEE (D3 decision-record, #1023) — the carve-out's one-shot
476+
semantic MIGRATES from binding-5-self-closure to MARKER-PRESENCE. Under CLI
477+
the old members[] check flipped True once the secretary joined, self-closing
478+
the carve-out. But under config-less Desktop members[] is STRUCTURALLY empty
479+
(no config.json), so binding 5 can no longer self-close — _secretary_in_members
480+
is always False there and the carve-out always fires pre-marker. The durable
481+
one-shot is now MARKER-PRESENCE: the is_marker_set fast-path in
482+
_check_tool_allowed returns None (allow-all) BEFORE the carve-out
483+
(this _is_canonical_secretary_spawn check) is ever reached in
484+
_check_tool_allowed, so once the marker is
485+
written the carve-out is moot. Documented so no future reader restores a
486+
binding-5 one-shot and re-deadlocks Desktop. The Desktop always-fire window
487+
is contained by bindings 1/2/3 (exact Agent + pact-secretary + secretary,
488+
all module constants) plus the marker fast-path that closes it.
418489
419490
On ANY disk-read exception, returns False — caller falls through to
420491
the existing _BLOCKED_TOOLS deny path so the user sees the canonical
421492
_DENY_REASON ("PACT bootstrap required...") rather than the
422493
load-failure variant. Mirrors is_marker_set's silent-on-exception
423-
style.
424-
425-
SACROSANCT — local-import discipline: _team_has_secretary is imported
426-
LOCALLY (function-call time, not module-load time) to break the
427-
reciprocal cycle with bootstrap_marker_writer, which imports
428-
is_marker_set from this module at its own top-level. Reciprocal
429-
top-level import here would deadlock module load and route every
430-
tool call through the fail-closed deny path.
494+
style. (_secretary_in_members has its own broad-except totality guard for
495+
the Path.home() RuntimeError seam — see its docstring.)
496+
497+
(The former SACROSANCT local-import discipline note is now OBSOLETE: binding
498+
5 no longer imports _team_has_secretary from bootstrap_marker_writer, so the
499+
reciprocal-cycle local-import is gone. The members[] read goes through the
500+
top-level-imported shared.pact_context, which bootstrap_marker_writer also
501+
imports at top level — no cycle. The one remaining cross-module edge is
502+
unchanged: bootstrap_marker_writer imports is_marker_set from THIS module at
503+
its own top level.)
431504
"""
432505
try:
433506
if input_data.get("tool_name") != "Agent":
@@ -445,13 +518,14 @@ def _is_canonical_secretary_spawn(input_data: dict) -> bool:
445518
expected_team = pact_context.get_team_name()
446519
if not expected_team:
447520
return False
448-
# Local-import: reciprocal-cycle prevention. bootstrap_marker_writer
449-
# imports is_marker_set from this module at its OWN top-level; a
450-
# reciprocal top-level import here would deadlock module load and
451-
# silently route every tool call through the fail-closed deny path.
452-
# See SACROSANCT block in this docstring.
453-
from bootstrap_marker_writer import _team_has_secretary
454-
return not _team_has_secretary(expected_team)
521+
# Binding 5: members[]-only JOIN witness (#1023). _secretary_in_members
522+
# is a gate-local helper reading shared.pact_context._iter_members (the
523+
# top-level-imported module) — NOT a cross-module import of
524+
# bootstrap_marker_writer._team_has_secretary (the DISPATCH witness,
525+
# which also accepts the inbox file and so re-deadlocked the spawn in
526+
# #1021). The former reciprocal-cycle local-import is gone. See the
527+
# join-vs-dispatch + D3 one-shot notes in this docstring.
528+
return not _secretary_in_members(expected_team)
455529
except (OSError, ValueError, KeyError, TypeError, AttributeError, ImportError):
456530
return False
457531

pact-plugin/hooks/merge_guard_post.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -449,9 +449,9 @@ def _retire_token_for_command(
449449
# Cycle-1 fail-OPEN-on-either-empty AND-short-circuit WAS
450450
# itself the attack surface — populated current_session +
451451
# empty token_session let attacker-written tokens through.
452-
# See test_merge_guard.py:5068 (test_no_session_id_accepts_any_token)
453-
# for the preserved invariant; :5086 inversion is the SEC-S1
454-
# fix landing.
452+
# See test_no_session_id_accepts_any_token (in test_merge_guard.py)
453+
# for the preserved invariant; its SEC-S1 inversion counterpart is
454+
# the fix landing.
455455
if not token_session or current_session != token_session:
456456
continue
457457
try:

pact-plugin/hooks/merge_guard_pre.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -802,9 +802,9 @@ def find_valid_token(token_dir: Path | None = None) -> tuple[dict, str] | tuple[
802802
# Cycle-1 fail-OPEN-on-either-empty AND-short-circuit WAS
803803
# itself the attack surface — populated current_session +
804804
# empty token_session let attacker-written tokens through.
805-
# See test_merge_guard.py:5068 (test_no_session_id_accepts_any_token)
806-
# for the preserved invariant; :5086 inversion is the SEC-S1
807-
# fix landing.
805+
# See test_no_session_id_accepts_any_token (in test_merge_guard.py)
806+
# for the preserved invariant; its SEC-S1 inversion counterpart
807+
# is the fix landing.
808808
if not token_session or current_session != token_session:
809809
continue
810810

pact-plugin/hooks/shared/hook_infra_classifier.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,20 @@
161161
"tool_response", "variety_scorer",
162162
}),
163163
"bootstrap_gate": frozenset({
164-
"claude_md_manager", "constants", "marker_schema", "pact_context",
165-
"paths", "pin_caps", "session_journal", "session_registry",
166-
"session_resume", "session_state", "staleness",
167-
}), # claude_md_manager / session_resume / staleness / pin_caps reached
168-
# here via bootstrap_gate -> bootstrap_marker_writer -> session_resume
169-
# (update_session_info) + claude_md_manager (resolve_project_claude_md_path),
170-
# and session_resume -> staleness -> pin_caps. Added when #989's
171-
# write-back self-heal pulled session_resume + claude_md_manager into
172-
# bootstrap_marker_writer's imports.
164+
"constants", "marker_schema", "pact_context",
165+
"paths", "session_journal", "session_registry",
166+
"session_state",
167+
}), # #1023 SHRANK this closure: the carve-out's binding 5 no longer
168+
# imports bootstrap_marker_writer (it reads the gate-local
169+
# _secretary_in_members JOIN witness via the already-top-level
170+
# pact_context._iter_members), so bootstrap_gate no longer reaches
171+
# bootstrap_marker_writer's transitive closure. The former extras
172+
# claude_md_manager / session_resume / staleness / pin_caps were
173+
# reachable ONLY through that deleted edge (bootstrap_gate ->
174+
# bootstrap_marker_writer -> session_resume (update_session_info) +
175+
# claude_md_manager (resolve_project_claude_md_path); session_resume ->
176+
# staleness -> pin_caps) and are now gone from this closure.
177+
# bootstrap_marker_writer's OWN closure (below) is unchanged.
173178
"bootstrap_marker_writer": frozenset({
174179
"claude_md_manager", "constants", "marker_schema", "pact_context",
175180
"paths", "pin_caps", "session_journal", "session_registry",

pact-plugin/hooks/shared/intentional_wait.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@
9292
#
9393
# Auditor signal-tasks are NOT in this set — they self-complete via
9494
# `metadata.completion_type == "signal"` + `metadata.type in {"blocker",
95-
# "algedonic"}` (the inline-literal pattern at task_utils.py:276 /
96-
# session_resume.py:522). Two distinct exemption surfaces:
95+
# "algedonic"}` (the same inline-literal pattern used in task_utils and
96+
# session_resume). Two distinct exemption surfaces:
9797
# - SELF_COMPLETE_EXEMPT_AGENT_TYPES: by team-config agentType lookup.
9898
# - signal-task pattern: by task metadata, applies to any agent.
9999
SELF_COMPLETE_EXEMPT_AGENT_TYPES: frozenset = frozenset({
@@ -352,8 +352,8 @@ def is_self_complete_exempt(
352352
surface short-circuits to False (fail-closed).
353353
2. By signal-task metadata: task.metadata.completion_type == "signal" AND
354354
task.metadata.type in {"blocker", "algedonic"}. Mirrors the inline
355-
literal at agent_handoff_emitter.py / task_utils.py:276 /
356-
session_resume.py:522. Independent of `team_name`.
355+
literal in agent_handoff_emitter / task_utils / session_resume.
356+
Independent of `team_name`.
357357
358358
Pure function; never raises on plain dicts (PACT task representation
359359
via json.loads); defaults to False on missing or malformed fields

pact-plugin/hooks/shared/session_state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def is_safe_path_component(value: str) -> bool:
142142
143143
Defense-in-depth against path-traversal via tampered session
144144
context (see security review Finding 2). The upstream allowlist
145-
lives at `session_init.py:401` — `re.sub(r"[^a-f0-9-]", "",
145+
lives in session_init's team-name generation — `re.sub(r"[^a-f0-9-]", "",
146146
session_id[:8])` — which already filters path separators, `..`,
147147
nulls, and controls at team-name generation time. This guard is a
148148
second line of defense at the I/O boundary.

0 commit comments

Comments
 (0)