Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/agent/subagent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/hooks/hook_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
35 changes: 21 additions & 14 deletions src/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
17 changes: 13 additions & 4 deletions src/services/startup_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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

Expand Down
23 changes: 20 additions & 3 deletions src/tool_system/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
35 changes: 30 additions & 5 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
22 changes: 22 additions & 0 deletions tests/test_subagent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_trust_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions tests/tui/test_startup_gates_c8.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down