diff --git a/src/agent/subagent_context.py b/src/agent/subagent_context.py index 59a0879c..b393853a 100644 --- a/src/agent/subagent_context.py +++ b/src/agent/subagent_context.py @@ -171,6 +171,10 @@ def create_subagent_context( output_style_dir=parent_context.output_style_dir, additional_working_directories=parent_context.additional_working_directories, allow_docs=parent_context.allow_docs, + # Inherit, don't re-seed from bootstrap state: trust stays monotone + # with the parent and the fork path stays independent of mutable + # globals (#275). + workspace_trusted=parent_context.workspace_trusted, permission_handler=permission_handler, options=options, abort_controller=abort_controller, diff --git a/src/hooks/hook_executor.py b/src/hooks/hook_executor.py index a1e6a455..a08a000c 100644 --- a/src/hooks/hook_executor.py +++ b/src/hooks/hook_executor.py @@ -387,7 +387,17 @@ async def _run_hooks_for_event( # need it. Phase-1 / WI-1.2 renamed ``POLICY`` → ``POLICY_SETTINGS``; # the ``is_policy`` predicate is the canonical way to ask the # question and shields callers from future renames. + gated = [h for h in event_hooks if not h.source.is_policy] event_hooks = [h for h in event_hooks if h.source.is_policy] + if gated: + # Breadcrumb for "why don't my hooks run": configured hooks + # silently not firing in an untrusted workspace is + # undiagnosable otherwise (#275). + logger.info( + "skipping %d non-policy %s hook(s): workspace is untrusted", + len(gated), + event, + ) tool_use_id = stdin_data.get("tool_use_id", str(uuid4())) parent_tool_use_id = "" diff --git a/src/init.py b/src/init.py index 0a1c23a1..9cda57a8 100644 --- a/src/init.py +++ b/src/init.py @@ -155,20 +155,27 @@ def run_pre_action(args: object) -> None: set_is_interactive(_determine_is_interactive(args)) set_client_type(_determine_client_type()) - # The trust dialog SHIPPED (components C8: - # ``services/startup_gates.check_trust_accepted`` + the TUI's - # TrustFolderScreen), but this placeholder must outlive it: the TUI - # is the only surface with a dialog — headless / -p / legacy-REPL - # sessions have no way to ask, and flipping the default to - # untrusted would hard-block their ``hooks/trust_gate.py`` and - # ``tool_system/context.py:workspace_trusted`` consumers with no - # way to consent. The dialog's accept path syncs this same flag via - # ``record_trust_accepted``, so narrowing this later only requires - # seeding interactive sessions from check_trust_accepted() here. - # TODO(components-C8 follow-up): seed from - # ``startup_gates.check_trust_accepted()`` for interactive sessions - # instead of unconditionally trusting. - set_session_trust_accepted(True) + # Seed session trust from the persisted per-project decision (C8 + # ``startup_gates.check_trust_accepted``: session flag, then cwd and + # every parent in the user-owned global config). Previously this was + # an unconditional ``True`` (#275): every surface — including ones + # with no dialog — implicitly trusted the workspace, so project + # hooks could fire without consent. Now: + # - previously-trusted dirs (or children) start trusted; + # - the TUI asks via TrustFolderScreen on first visit and + # ``record_trust_accepted`` syncs this flag on accept; + # - headless / -p / legacy-REPL sessions in never-trusted dirs run + # untrusted: non-policy hooks are skipped (fail-safe, mirrors the + # TS shouldSkipHookDueToTrust gate) until the dir is trusted once + # via the TUI. + try: + from src.services.startup_gates import check_trust_accepted + + set_session_trust_accepted(check_trust_accepted()) + except Exception: + # Fail CLOSED: an errored consent check must not wave the + # workspace through. + set_session_trust_accepted(False) profile_checkpoint("pre_action_end") diff --git a/src/services/startup_gates.py b/src/services/startup_gates.py index 5c0af05b..710f2e2f 100644 --- a/src/services/startup_gates.py +++ b/src/services/startup_gates.py @@ -57,6 +57,15 @@ def reset_session_trust_for_testing() -> None: global _session_trust_accepted _session_trust_accepted = False + # record_trust_accepted syncs the bootstrap flag (consumed by + # ToolContext.workspace_trusted seeding — #275); reset that too so + # trust acceptance can't leak across tests. + try: + from src.bootstrap.state import set_session_trust_accepted + + set_session_trust_accepted(False) + except Exception: + pass # --------------------------------------------------------------------------- @@ -94,10 +103,10 @@ def record_trust_accepted(cwd: str | Path | None = None) -> bool: global _session_trust_accepted _session_trust_accepted = True # Keep the BOOTSTRAP session-trust flag (the port of the same TS - # symbol, consumed by hooks/trust_gate.py and - # tool_system/context.workspace_trusted) in sync — today init.py - # pre-sets it True for every entrypoint, but once that placeholder - # narrows, an accepted dialog must still propagate trust there. + # symbol, seeded by init.py pre_action from the persisted decision + # and consumed by ToolContext.workspace_trusted seeding — #275) in + # sync, so contexts constructed after an accepted dialog start + # trusted. try: from src.bootstrap.state import set_session_trust_accepted diff --git a/src/tool_system/context.py b/src/tool_system/context.py index 01824307..8c754ab9 100644 --- a/src/tool_system/context.py +++ b/src/tool_system/context.py @@ -63,6 +63,19 @@ class GlobLimits: max_results: int | None = None +def _session_trust_seed() -> bool: + """Default for ``ToolContext.workspace_trusted``: the bootstrap + session-trust flag (seeded by ``pre_action`` from the persisted + per-project decision, synced by the trust dialog's accept path). + Fail-safe False if bootstrap state is unavailable.""" + try: + from src.bootstrap.state import get_session_trust_accepted + + return get_session_trust_accepted() + except Exception: + return False + + @dataclass class ToolContext: workspace_root: Path @@ -212,11 +225,15 @@ class ToolContext: # callers that still pass hooks via options get a ``DeprecationWarning`` # but their behavior is preserved. hook_config_manager: Any | None = None - # Chapter-12 / Phase 0 / WI-0.2 — workspace-trust gate. Bootstrap flips - # this to ``True`` after the user accepts the trust dialog. Hooks (other + # Chapter-12 / Phase 0 / WI-0.2 — workspace-trust gate. Hooks (other # than ``HookSource.POLICY_SETTINGS``) are skipped while the workspace is # untrusted, mirroring TS' ``shouldSkipHookDueToTrust`` gate. - workspace_trusted: bool = False + # Seeded from bootstrap session trust at construction (#275): pre_action + # sets it from the persisted per-project decision, and the trust + # dialog's accept path syncs it via ``record_trust_accepted`` — a + # context built before the dialog must be flipped by the accepting + # surface (the TUI does; see ``_on_trust_choice``). + workspace_trusted: bool = field(default_factory=_session_trust_seed) # Chapter-9 / Fork Agents — captured bytes of the system prompt used on # the parent's most recent API call. Threaded into fork children so the diff --git a/src/tui/app.py b/src/tui/app.py index 09581e53..5b1fe3d4 100644 --- a/src/tui/app.py +++ b/src/tui/app.py @@ -270,6 +270,11 @@ def _on_trust_choice(self, choice: str | None) -> None: persisted = record_trust_accepted() except Exception: persisted = False + # The tool context was built before the dialog ran (untrusted seed); + # propagate acceptance so hooks stop being trust-skipped (#275). + # record_trust_accepted already synced the bootstrap flag for any + # contexts constructed after this point. + self.tool_context.workspace_trusted = True if not persisted and self._repl_screen is not None: self._repl_screen.transcript.append_system( "Folder trusted for this session only — could not write " diff --git a/tests/test_init.py b/tests/test_init.py index be8b403e..144abc3b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -194,17 +194,42 @@ def test_unknown_value_falls_back_to_cli(self) -> None: class TestRunPreActionSetsTrustAccepted(unittest.TestCase): - """A6 / P1.6: plan-phase-1 default is `set_session_trust_accepted(True)` - so existing trust-gate consumers behave correctly.""" + """#275: pre_action seeds session trust from the persisted per-project + decision (startup_gates.check_trust_accepted) instead of hardcoding True.""" - def test_pre_action_sets_trust_accepted_true(self) -> None: - # Pre-state: default False. - self.assertFalse(get_session_trust_accepted()) + def tearDown(self) -> None: + from src.bootstrap.state import set_session_trust_accepted + + set_session_trust_accepted(False) + + def _run(self) -> None: with mock.patch.object(init_module, "init"): args = types.SimpleNamespace(print=False) init_module.run_pre_action(args) + + def test_previously_trusted_dir_seeds_true(self) -> None: + self.assertFalse(get_session_trust_accepted()) + with mock.patch( + "src.services.startup_gates.check_trust_accepted", return_value=True + ): + self._run() self.assertTrue(get_session_trust_accepted()) + def test_never_trusted_dir_seeds_false(self) -> None: + with mock.patch( + "src.services.startup_gates.check_trust_accepted", return_value=False + ): + self._run() + self.assertFalse(get_session_trust_accepted()) + + def test_errored_trust_check_fails_closed(self) -> None: + with mock.patch( + "src.services.startup_gates.check_trust_accepted", + side_effect=RuntimeError("boom"), + ): + self._run() + self.assertFalse(get_session_trust_accepted()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_subagent_context.py b/tests/test_subagent_context.py index 27ae9511..3be46625 100644 --- a/tests/test_subagent_context.py +++ b/tests/test_subagent_context.py @@ -26,6 +26,28 @@ def _make_parent_context(**kwargs) -> ToolContext: return ToolContext(**defaults) +# --- Trust inheritance (#275) --- + +class TestWorkspaceTrustInheritance: + def test_trusted_parent_forks_trusted_child(self): + parent = _make_parent_context(workspace_trusted=True) + child = create_subagent_context(parent) + assert child.workspace_trusted is True + + def test_untrusted_parent_forks_untrusted_child_despite_global_trust(self): + # The fork inherits from the parent, not from mutable bootstrap + # state — trust stays monotone with the parent. + from src.bootstrap.state import set_session_trust_accepted + + set_session_trust_accepted(True) + try: + parent = _make_parent_context(workspace_trusted=False) + child = create_subagent_context(parent) + assert child.workspace_trusted is False + finally: + set_session_trust_accepted(False) + + # --- Default isolation --- class TestDefaultIsolation: diff --git a/tests/test_trust_gate.py b/tests/test_trust_gate.py index 7bcaa0e2..c0b2eb1c 100644 --- a/tests/test_trust_gate.py +++ b/tests/test_trust_gate.py @@ -166,3 +166,40 @@ async def test_untrusted_workspace_with_only_user_hooks_yields_nothing(self): items.append(r) assert items == [] + + +# --------------------------------------------------------------------------- +# #275: ToolContext.workspace_trusted seeds from bootstrap session trust +# --------------------------------------------------------------------------- + + +class TestToolContextTrustSeeding: + def teardown_method(self) -> None: + from src.bootstrap.state import set_session_trust_accepted + + set_session_trust_accepted(False) + + def _make_context(self, tmp_path: Path): + from src.tool_system.context import ToolContext + + return ToolContext(workspace_root=tmp_path) + + def test_untrusted_session_seeds_false(self, tmp_path): + from src.bootstrap.state import set_session_trust_accepted + + set_session_trust_accepted(False) + assert self._make_context(tmp_path).workspace_trusted is False + + def test_trusted_session_seeds_true(self, tmp_path): + from src.bootstrap.state import set_session_trust_accepted + + set_session_trust_accepted(True) + assert self._make_context(tmp_path).workspace_trusted is True + + def test_explicit_value_wins_over_seed(self, tmp_path): + from src.bootstrap.state import set_session_trust_accepted + from src.tool_system.context import ToolContext + + set_session_trust_accepted(True) + ctx = ToolContext(workspace_root=tmp_path, workspace_trusted=False) + assert ctx.workspace_trusted is False diff --git a/tests/tui/test_startup_gates_c8.py b/tests/tui/test_startup_gates_c8.py index c1cae682..fe4120e1 100644 --- a/tests/tui/test_startup_gates_c8.py +++ b/tests/tui/test_startup_gates_c8.py @@ -304,6 +304,9 @@ def test_trust_accept_persists_and_continues(self, proj) -> None: fake, rows, pushes, exits, finished = self._fake(proj) fake._on_trust_choice("trust") assert check_trust_accepted(proj) + # #275: acceptance propagates to the already-built tool context so + # hooks stop being trust-skipped mid-session. + assert fake.tool_context.workspace_trusted is True assert finished == ["warnings", "mcp"] def test_bypass_gate_prompts_only_in_bypass_mode(self, proj) -> None: