diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3be4310..621fecf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -479,7 +479,7 @@ The roadblock protocol (section 11.6) and the model edge cases (section 24.6) ar Handler-level checks for the same conditions (e.g. the `if mode not in WRITE_MODES` guard in `_write_file`, the `if status not in STEP_STATUSES` guard in `_update`) remain in place as defense in depth; in normal flow they are unreachable for those parameters. -**`max_plan_steps` enforcement (v0.5.2).** `RuntimeSettings.max_plan_steps` (default 10) is now enforced at proposal time. A `propose_plan` call whose `steps` list exceeds the limit returns a corrective failure (`plan has N steps; max is M — consolidate related steps and propose again`) via the normal `ToolResult(success=False, ...)` path — not the malformed-call path — because the step count is a policy decision, not a schema error. The setting is threaded from `Settings.runtime.max_plan_steps` through `ConversationRuntime` to `make_plan_tools(max_plan_steps=...)` at construction time. +**`max_plan_steps` enforcement (v0.5.2).** `RuntimeSettings.max_plan_steps` (default 10) is now enforced at proposal time. A `propose_plan` call whose `steps` list exceeds the limit returns a corrective failure (`plan has N steps; max is M — consolidate related steps and propose again`) via the normal `ToolResult(success=False, ...)` path — not the malformed-call path — because the step count is a policy decision, not a schema error. The limit lives on `PlanManager.max_plan_steps` (seeded from `Settings.runtime.max_plan_steps` when the manager is constructed) and is read *live* at proposal time: `make_plan_tools` is called with no `max_plan_steps` argument, so its `_limit()` reads `manager.max_plan_steps` on every `propose_plan`. A `/config set runtime.max_plan_steps` therefore takes effect the same session — `update_settings` writes the new value onto `plan_manager.max_plan_steps` without rebuilding the plan tools. ### 10.5 Context And Output Budgeting @@ -1409,6 +1409,7 @@ The project `/AGENTS.md` is injected as standing "Project instruction - The **project** `/AGENTS.md` is loaded only after the user accepts it. Acceptance is recorded in `.shellpilot/state.json` as the `trusted_agents_md` SHA-256 digest of the file's raw bytes. - On boot, the current digest is compared to the recorded one. If they match, the file loads without prompting. If it is new or its content has changed since it was last trusted (any byte change flips the digest), the user is re-prompted (default No) and the file is loaded only on acceptance. - A **non-TTY** session fails closed: the project file is not loaded (no way to obtain consent). +- The same gate runs **mid-session on `/cwd set `**: switching the workspace re-resolves trust for the *new* directory's `AGENTS.md` inside `SlashDispatcher._reload_behavior_for_workspace` before its instructions are injected. An already-trusted destination loads without prompting; a new or changed destination re-prompts (default No); a non-TTY session fails closed. Either way the previous workspace's project instructions are dropped, so a switch into an untrusted repo can never keep injecting the old rules or silently pick up the new ones. The always-trusted global `AGENTS.md` is reloaded unchanged. Storing the digest never clobbers the recorded last-selected model; both keys coexist in `state.json` via read-merge-write. @@ -1683,9 +1684,11 @@ Three slash commands manage the overrides file at runtime: the error is printed in red and the file is never touched. This invariant means a corrupt entry can never reach `overrides.json` via the CLI. The new value takes effect immediately for live keys (via `update_settings`). - A subset of keys are *boot-only* (theme, model client, tool registration, + A subset of keys are *boot-only* (theme, model client, web-tool registration, keep_alive preload, etc.); for those a dim note is appended: "takes effect - next session". For `model.default` specifically the note adds "use + next session". (Not all tool registration is boot-only: `update_settings` + re-syncs the `skill_read` tool live when `skills.enabled` becomes empty or + non-empty, registering or unregistering it without a restart.) For `model.default` specifically the note adds "use `/model use ` to switch now". Setting a **high-stakes key** (`HIGH_STAKES_KEYS` — `tools.web`, `model.base_url`, `runtime.security_profile`, `model.allow_cloud`) first @@ -3154,6 +3157,8 @@ Because a high-stakes override persists in `overrides.json` and silently outrank A project `AGENTS.md` is injected as standing instructions with the same authority as ShellPilot's own prompt, so cloning and running in an untrusted repo could load attacker-authored instructions every turn (and, under cloud, egress them turn one). The project (workspace) `AGENTS.md` is now gated behind per-workspace **trust-on-first-use**: its SHA-256 content digest (`project_agents_md_digest`, `memory/agents_md.py`) is recorded in program-managed workspace state (`.shellpilot/state.json`) once the user accepts it. A non-TTY session fails closed (the file is skipped), and because the gate keys on the content digest, **any change to the file re-prompts** before the new content is trusted. The global config-dir `AGENTS.md` stays trusted (it is the user's own). +The gate is not boot-only: a mid-session `/cwd set ` re-runs it for the new workspace (`SlashDispatcher._reload_behavior_for_workspace` → `_resolve_project_agents_trust`) before any of the destination's instructions are injected. The switch always drops the previous workspace's project instructions, then loads the new project `AGENTS.md` only if its digest is already trusted or the user accepts it this session (non-TTY fails closed). This closes the stale-instructions gap where a session that switched into an untrusted repo would otherwise keep injecting the prior workspace's rules — or silently adopt the new repo's — without a trust decision. + ### 36.5 Egress Chokepoint and Audit A single locality predicate, `is_egressing(model, base_url)` (`config/model.py`), is the one source of truth for whether a session is off-box — true for a cloud model (the Ollama cloud tag — `:cloud` or a sized `-cloud` — via `is_cloud_model`) or a non-loopback `base_url` (`is_loopback_url`, `llm/ollama.py`). At the conversational chokepoint (`conversation.py` tool loop; the `/memory compact` residual is noted in section 15.1): diff --git a/shellpilot/cli/slash.py b/shellpilot/cli/slash.py index 1a57445..2855824 100644 --- a/shellpilot/cli/slash.py +++ b/shellpilot/cli/slash.py @@ -746,10 +746,32 @@ def _cwd(self, args: list[str]) -> None: return if self._confirm(f"Change the workspace boundary to {new_workspace}?"): self._runtime.set_workspace(new_workspace) + self._reload_behavior_for_workspace(new_workspace) self._console.print(f"Workspace boundary: {new_workspace}") return self._console.print("Usage: /cwd | /cwd set ") + def _reload_behavior_for_workspace(self, workspace: Path) -> None: + """Trust-gate and reload AGENTS.md for the new workspace after ``/cwd``.""" + from shellpilot.cli.terminal import _resolve_project_agents_trust + from shellpilot.memory.agents_md import BehaviorInstructions, load_behavior_instructions + + settings = self._runtime.settings + if not settings.instructions.load_agents_md: + self._runtime.set_behavior(BehaviorInstructions(global_text=None, project_text=None)) + return + config_dir = self._user_config_file.parent + detected = self._runtime.budget.model_context_tokens + cap = min(1500, max(1, detected // 10)) + project_trusted = _resolve_project_agents_trust(self._console, workspace, tty=self._tty) + behavior = load_behavior_instructions( + config_dir, + workspace, + max_tokens=cap, + project_trusted=project_trusted, + ) + self._runtime.set_behavior(behavior) + def _doctor(self) -> None: from shellpilot.cli.doctor import run_doctor @@ -784,7 +806,6 @@ def _profile(self, args: list[str]) -> None: self._runtime.update_settings(dataclasses.replace(settings, runtime=new_runtime)) if self._runtime.audit is not None: self._runtime.audit.write("config_change", setting="profile", value=name) - self._runtime.audit.profile = name self._console.print(f"Switched to profile: {name}") return self._console.print("Usage: /profile | /profile use ") diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 284686e..bf56b94 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -783,7 +783,7 @@ def _status_values() -> StatusValues: return StatusValues( workspace=st.workspace, model=runtime.model, - profile=settings.runtime.security_profile, + profile=st.profile, is_cloud=is_egressing(runtime.model, settings.model.base_url), ctx_pct=ctx_percent(st.estimated_prompt_tokens, st.budget.model_context_tokens), ) diff --git a/shellpilot/runtime/conversation.py b/shellpilot/runtime/conversation.py index 08345ff..8998ba4 100644 --- a/shellpilot/runtime/conversation.py +++ b/shellpilot/runtime/conversation.py @@ -164,7 +164,11 @@ def __init__( self._cancel: threading.Event | None = None self.snapshots = SnapshotStore() self.recent_diffs: list[str] = [] - self.plan_manager = PlanManager(workspace, settings.runtime.security_profile) + self.plan_manager = PlanManager( + workspace, + settings.runtime.security_profile, + max_plan_steps=settings.runtime.max_plan_steps, + ) self._last_recorded_plan_ptr: str | None | _Unset = _UNSET self.plan_manager.on_change = self._on_plan_change for spec in make_plan_tools( @@ -172,7 +176,6 @@ def __init__( ui.ask_plan_approval, lambda: self._last_user_text, on_step_change=ui.show_plan_progress, - max_plan_steps=settings.runtime.max_plan_steps, ): self._registry.register(spec) self._registry.register( @@ -184,7 +187,12 @@ def __init__( if memory is not None: from shellpilot.tools.memory_tools import make_memory_tools - for spec in make_memory_tools(memory): + def _live_memory() -> MemoryStores: + if self._memory is None: + raise RuntimeError("memory tools registered without live memory stores") + return self._memory + + for spec in make_memory_tools(_live_memory): self._registry.register(spec) if settings.tools.web: from shellpilot.tools.web import default_web_tools @@ -280,6 +288,8 @@ def set_workspace(self, workspace: Path) -> None: # so a /cwd change must rebuild the project store for the new path — # otherwise the previous workspace's facts keep injecting (and, under # cloud, egressing). The shared global store is preserved as-is. + # Memory tools resolve stores through a getter, so they follow this + # replacement automatically. self._memory = dataclasses.replace( self._memory, project_store=MemoryStore( @@ -296,9 +306,37 @@ def set_workspace(self, workspace: Path) -> None: self._audit.workspace = workspace self._audit.write("config_change", setting="workspace", value=str(workspace)) + def set_behavior(self, behavior: BehaviorInstructions) -> None: + """Replace standing AGENTS.md instructions (used after ``/cwd`` trust).""" + self._behavior = behavior + def update_settings(self, settings: Settings) -> None: + """Apply live settings and refresh dependents that must stay in sync. + + Boot-only keys (for example ``tools.web``) may still leave registered + tools unchanged by design; this method keeps the live surface coherent + for profile, plan limits, audit metadata, and skill_read availability. + """ + previous = self._settings self._settings = settings self.budget = self._resolve_budget() + self.plan_manager.set_profile(settings.runtime.security_profile) + self.plan_manager.max_plan_steps = settings.runtime.max_plan_steps + if self._audit is not None: + self._audit.profile = settings.runtime.security_profile + self._sync_skill_read_tool(previous.skills.enabled, settings.skills.enabled) + + def _sync_skill_read_tool(self, was_enabled: tuple[str, ...], enabled: tuple[str, ...]) -> None: + """Keep skill_read registered iff the enabled-skills list is non-empty.""" + if bool(was_enabled) == bool(enabled): + return + from shellpilot.tools.skill_tools import make_skill_read_tool + + if enabled: + valid_skills = tuple(s for s in self._skills if s.valid) + self._registry.replace(make_skill_read_tool(valid_skills)) + else: + self._registry.unregister("skill_read") def _endpoint_host(self) -> str: """Host of the model endpoint (for audit); empty when unparseable.""" diff --git a/shellpilot/runtime/planner.py b/shellpilot/runtime/planner.py index 979a7ae..0e2209f 100644 --- a/shellpilot/runtime/planner.py +++ b/shellpilot/runtime/planner.py @@ -162,9 +162,10 @@ def compact_plan_state(plan: TaskPlan) -> str: class PlanManager: """Owns the active plan and its artifact on disk.""" - def __init__(self, workspace: Path, profile: str) -> None: + def __init__(self, workspace: Path, profile: str, *, max_plan_steps: int = 10) -> None: self._workspace = workspace self._profile = profile + self._max_plan_steps = max_plan_steps self.active: TaskPlan | None = None self.pending_revision: str | None = None # Transient completion-guard state (runtime-only; never persisted to @@ -181,6 +182,18 @@ def set_workspace(self, workspace: Path) -> None: """New tasks use the new boundary; an active plan keeps its artifact path.""" self._workspace = workspace + def set_profile(self, profile: str) -> None: + """New tasks stamp *profile*; an active plan keeps its recorded profile.""" + self._profile = profile + + @property + def max_plan_steps(self) -> int: + return self._max_plan_steps + + @max_plan_steps.setter + def max_plan_steps(self, value: int) -> None: + self._max_plan_steps = value + def artifact_path(self, plan: TaskPlan) -> Path: # Pinned to the plan's own workspace (set at create, persisted, restored), # not the mutable self._workspace — so a mid-plan /cwd keeps PLAN.md where @@ -393,9 +406,16 @@ def make_plan_tools( ask_plan_approval: PlanApprovalAsker, get_user_intent: UserIntentGetter, on_step_change: PlanProgressShower | None = None, - max_plan_steps: int = 10, + max_plan_steps: int | None = None, ) -> list[ToolSpec]: - """Plan tools close over the manager and the UI approval flow.""" + """Plan tools close over the manager and the UI approval flow. + + When *max_plan_steps* is omitted, the live ``manager.max_plan_steps`` value + is used so ``/config`` changes take effect without rebuilding the tools. + """ + + def _limit() -> int: + return manager.max_plan_steps if max_plan_steps is None else max_plan_steps def _propose(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: goal = str(arguments["goal"]).strip() @@ -405,13 +425,14 @@ def _propose(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: success=False, summary="plan needs a goal and at least one step", content="" ) - if len(steps) > max_plan_steps: + limit = _limit() + if len(steps) > limit: n = len(steps) return ToolResult( success=False, - summary=f"plan has {n} steps; max is {max_plan_steps}", + summary=f"plan has {n} steps; max is {limit}", content=( - f"plan has {n} steps; max is {max_plan_steps} — consolidate related " + f"plan has {n} steps; max is {limit} — consolidate related " "steps and propose again" ), ) diff --git a/shellpilot/tools/memory_tools.py b/shellpilot/tools/memory_tools.py index 4ac66e3..cee8d6a 100644 --- a/shellpilot/tools/memory_tools.py +++ b/shellpilot/tools/memory_tools.py @@ -9,6 +9,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any from shellpilot.llm.messages import ToolDefinition @@ -38,8 +39,16 @@ def _diff_preview(title: str, lines: list[str]) -> str: return f"--- a/{title}\n+++ b/{title}\n@@ -1 +1 @@\n{body}\n" -def make_memory_tools(stores: MemoryStores) -> list[ToolSpec]: +def make_memory_tools(get_stores: Callable[[], MemoryStores]) -> list[ToolSpec]: + """Build memory tools that resolve stores through *get_stores* each call. + + Handlers must not close over a concrete ``MemoryStores`` instance: ``/cwd`` + replaces the runtime's project store, and a captured reference would keep + reading and writing the previous workspace. + """ + def _read(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: + stores = get_stores() block = stores.render(max_tokens=PREVIEW_TOKENS) if not block: return ToolResult( @@ -50,6 +59,7 @@ def _read(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: return ToolResult(success=True, summary=f"{entries} memory entries", content=block) def _propose(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: + stores = get_stores() action = str(arguments.get("action", "")) try: if action == "add_preference": @@ -106,6 +116,7 @@ def _propose(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: ) def _propose_preview(context: ToolContext, arguments: dict[str, Any]) -> str: + stores = get_stores() action = str(arguments.get("action", "")) if action == "add_preference": scope = str(arguments.get("scope", "global")) diff --git a/shellpilot/tools/registry.py b/shellpilot/tools/registry.py index 776c123..2f0e92d 100644 --- a/shellpilot/tools/registry.py +++ b/shellpilot/tools/registry.py @@ -15,6 +15,13 @@ def register(self, spec: ToolSpec) -> None: raise ValueError(f"tool already registered: {spec.name}") self._specs[spec.name] = spec + def replace(self, spec: ToolSpec) -> None: + """Register or overwrite a tool by name (for live settings transitions).""" + self._specs[spec.name] = spec + + def unregister(self, name: str) -> None: + self._specs.pop(name, None) + def get(self, name: str) -> ToolSpec | None: return self._specs.get(name) diff --git a/tests/test_conversation.py b/tests/test_conversation.py index db82d8e..db8c824 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -1066,6 +1066,127 @@ def test_set_workspace_rebuilds_project_memory(tmp_path: Path) -> None: assert "postgres-b" in runtime.context_snapshot().system_text() +def test_set_workspace_memory_tools_follow_new_project_store(tmp_path: Path) -> None: + """memory_read / memory_propose_update must use the live project store after /cwd.""" + from shellpilot.memory.store import MemoryStore, MemoryStores, project_id_for + from shellpilot.persistence.paths import project_state_dir + from shellpilot.tools.base import ToolContext + + workspace_a = tmp_path / "a" + workspace_b = tmp_path / "b" + workspace_a.mkdir() + workspace_b.mkdir() + + stores = MemoryStores( + global_store=MemoryStore(tmp_path / "global-memory.json"), + project_store=MemoryStore( + project_state_dir(workspace_a) / "memory.json", + project_id=project_id_for(workspace_a), + ), + ) + stores.project_store.add_fact(kind="config", value="postgres-a", label="db", source="user") + + runtime = ConversationRuntime( + llm=FakeLLM(script=[]), + settings=Settings(), + workspace=workspace_a, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=FakeUI(), + memory=stores, + ) + read_spec = next(s for s in runtime.registry.specs() if s.name == "memory_read") + propose_spec = next(s for s in runtime.registry.specs() if s.name == "memory_propose_update") + ctx = ToolContext(workspace=workspace_a, max_result_tokens=4096) + + before = read_spec.handler(ctx, {}) + assert "postgres-a" in before.content + + runtime.set_workspace(workspace_b) + after = read_spec.handler(ctx, {}) + assert "postgres-a" not in after.content + + proposed = propose_spec.handler( + ctx, + {"action": "add_fact", "kind": "config", "label": "db", "value": "postgres-b"}, + ) + assert proposed.success + assert runtime.memory is not None + assert any(f.value == "postgres-b" for f in runtime.memory.project_store.facts) + assert not any(f.value == "postgres-b" for f in stores.project_store.facts) + + +def test_update_settings_keeps_plan_limits_and_audit_profile_live(tmp_path: Path) -> None: + """Live settings must refresh plan max steps, new-plan profile, and audit profile.""" + import dataclasses + + from shellpilot.persistence.audit_store import AuditLogger + from shellpilot.tools.base import ToolContext + + audit = AuditLogger( + tmp_path / "audit.jsonl", + session_id="s1", + workspace=tmp_path, + profile="balanced", + ) + runtime = ConversationRuntime( + llm=FakeLLM(script=[]), + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=FakeUI(), + audit=audit, + ) + propose = next(s for s in runtime.registry.specs() if s.name == "propose_plan") + ctx = ToolContext(workspace=tmp_path, max_result_tokens=4096) + + new_runtime = dataclasses.replace( + runtime.settings.runtime, max_plan_steps=2, security_profile="supervised" + ) + runtime.update_settings(dataclasses.replace(runtime.settings, runtime=new_runtime)) + + assert audit.profile == "supervised" + assert runtime.plan_manager.max_plan_steps == 2 + too_many = propose.handler( + ctx, + { + "goal": "Ship it", + "steps": ["one", "two", "three"], + "assumptions": [], + "verification": [], + }, + ) + assert not too_many.success + assert "max is 2" in too_many.summary + + ok = propose.handler( + ctx, + { + "goal": "Ship it", + "steps": ["one", "two"], + "assumptions": [], + "verification": [], + }, + ) + assert ok.success + assert runtime.plan_manager.active is not None + assert runtime.plan_manager.active.profile == "supervised" + + +def test_set_behavior_updates_context_prompt(tmp_path: Path) -> None: + runtime = ConversationRuntime( + llm=FakeLLM(script=[]), + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text="old project rules"), + ui=FakeUI(), + ) + assert "old project rules" in runtime.context_snapshot().system_text() + runtime.set_behavior(BehaviorInstructions(global_text=None, project_text="new project rules")) + text = runtime.context_snapshot().system_text() + assert "new project rules" in text + assert "old project rules" not in text + + def test_update_plan_after_clear_reports_no_active_plan(tmp_path: Path) -> None: """After clear, the update_plan tool handler returns 'no active plan'.""" fake = FakeLLM( @@ -1447,6 +1568,26 @@ def test_skill_read_registered_when_skills_enabled(tmp_path: Path) -> None: assert runtime.registry.get("skill_read") is not None +def test_update_settings_syncs_skill_read_registration(tmp_path: Path) -> None: + """update_settings registers skill_read when skills.enabled becomes non-empty + and unregisters it when the list empties again.""" + runtime = ConversationRuntime( + llm=FakeLLM(script=[]), + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=FakeUI(), + skills=_real_builtin_skills(), + ) + assert runtime.registry.get("skill_read") is None + + runtime.update_settings(Settings(skills=SkillSettings(enabled=("skill-authoring",)))) + assert runtime.registry.get("skill_read") is not None + + runtime.update_settings(Settings(skills=SkillSettings(enabled=()))) + assert runtime.registry.get("skill_read") is None + + def test_tool_guide_tracks_registered_optional_tools(tmp_path: Path) -> None: """The prompt guide names optional tools only when those tools are registered.""" default_runtime = make_runtime(FakeLLM(script=[]), FakeUI(), tmp_path, settings=Settings()) diff --git a/tests/test_slash.py b/tests/test_slash.py index 921c255..56e216a 100644 --- a/tests/test_slash.py +++ b/tests/test_slash.py @@ -20,6 +20,7 @@ def __init__( tmp_path: Path, confirm_answer: bool = True, session: SessionStore | None = None, + tty: bool = True, ) -> None: self.console = Console(record=True, width=100) self.fake = FakeLLM(script=[answer("hello")]) @@ -46,6 +47,7 @@ def reload_config() -> LoadedConfig: user_config_file=tmp_path / "config.toml", reload_config=reload_config, confirm=lambda prompt: confirm_answer, + tty=tty, ) @staticmethod @@ -598,6 +600,95 @@ def test_cwd_set_reflected_in_runtime_status(tmp_path: Path) -> None: assert harness.runtime.status().workspace == new_workspace.resolve() +# --------------------------------------------------------------------------- +# /cwd set re-runs AGENTS.md trust-on-first-use for the new workspace +# --------------------------------------------------------------------------- + + +def _prepare_cwd_trust(tmp_path: Path, tty: bool) -> tuple["Harness", Path]: + """Harness with a global AGENTS.md, an untrusted new workspace with its own + project AGENTS.md, and the runtime pre-seeded with the old workspace's + project instructions so a switch can be observed to drop them.""" + (tmp_path / "AGENTS.md").write_text("GLOBAL RULE ALPHA", encoding="utf-8") + new_workspace = tmp_path / "other" + new_workspace.mkdir() + (new_workspace / "AGENTS.md").write_text("PROJECT RULE BETA", encoding="utf-8") + harness = Harness(tmp_path, confirm_answer=True, tty=tty) + harness.runtime.set_behavior( + BehaviorInstructions(global_text="GLOBAL RULE ALPHA", project_text="OLD PROJECT RULE") + ) + return harness, new_workspace + + +def test_cwd_set_non_tty_fails_closed_dropping_old_project_rules(tmp_path: Path) -> None: + """A non-TTY /cwd set drops the old project rules and does NOT load the new + (untrusted) ones, while the global rules are preserved.""" + harness, new_workspace = _prepare_cwd_trust(tmp_path, tty=False) + + harness.dispatcher.handle(f"/cwd set {new_workspace}") + + behavior = harness.runtime._behavior + assert behavior.project_text is None + assert behavior.global_text == "GLOBAL RULE ALPHA" + + +def test_cwd_set_decline_fails_closed_dropping_old_project_rules(tmp_path: Path) -> None: + """Declining the trust prompt on /cwd set drops the old project rules, + leaves the new project AGENTS.md unloaded, and persists no digest.""" + from shellpilot.persistence.workspace_state import load_trusted_agents_digest + + harness, new_workspace = _prepare_cwd_trust(tmp_path, tty=True) + harness.console.input = lambda *a, **k: "n" # type: ignore[method-assign] + + harness.dispatcher.handle(f"/cwd set {new_workspace}") + + behavior = harness.runtime._behavior + assert behavior.project_text is None + assert behavior.global_text == "GLOBAL RULE ALPHA" + assert load_trusted_agents_digest(new_workspace) is None + + +def test_cwd_set_accept_loads_new_rules_and_persists_digest(tmp_path: Path) -> None: + """Accepting the trust prompt on /cwd set loads the new project AGENTS.md + and records its digest so a later visit is trusted without prompting.""" + from shellpilot.memory.agents_md import project_agents_md_digest + from shellpilot.persistence.workspace_state import load_trusted_agents_digest + + harness, new_workspace = _prepare_cwd_trust(tmp_path, tty=True) + harness.console.input = lambda *a, **k: "y" # type: ignore[method-assign] + + harness.dispatcher.handle(f"/cwd set {new_workspace}") + + behavior = harness.runtime._behavior + assert behavior.project_text is not None + assert "PROJECT RULE BETA" in behavior.project_text + assert behavior.global_text == "GLOBAL RULE ALPHA" + assert load_trusted_agents_digest(new_workspace) == project_agents_md_digest(new_workspace) + + +def test_cwd_set_previously_trusted_workspace_does_not_reprompt(tmp_path: Path) -> None: + """A /cwd set into a workspace whose current AGENTS.md digest is already + trusted loads its rules without prompting.""" + from shellpilot.memory.agents_md import project_agents_md_digest + from shellpilot.persistence.workspace_state import save_trusted_agents_digest + + harness, new_workspace = _prepare_cwd_trust(tmp_path, tty=True) + digest = project_agents_md_digest(new_workspace) + assert digest is not None + save_trusted_agents_digest(new_workspace, digest) + + def _no_prompt(*_a: object, **_k: object) -> str: + raise AssertionError("trusted workspace must not re-prompt") + + harness.console.input = _no_prompt # type: ignore[method-assign] + + harness.dispatcher.handle(f"/cwd set {new_workspace}") + + behavior = harness.runtime._behavior + assert behavior.project_text is not None + assert "PROJECT RULE BETA" in behavior.project_text + + # --------------------------------------------------------------------------- # Fix 3: /logs scoped to current session # ---------------------------------------------------------------------------