agentshim 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {agentshim-0.12.0 → agentshim-0.13.0}/CHANGELOG.md +19 -0
  2. {agentshim-0.12.0 → agentshim-0.13.0}/PKG-INFO +1 -1
  3. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/__init__.py +5 -1
  4. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/agent.py +24 -5
  5. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/__init__.py +4 -0
  6. agentshim-0.13.0/agentshim/core/home.py +90 -0
  7. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/profile.py +27 -0
  8. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/provider.py +4 -1
  9. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/provider.py +14 -1
  10. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/provider.py +36 -0
  11. {agentshim-0.12.0 → agentshim-0.13.0}/pyproject.toml +1 -1
  12. {agentshim-0.12.0 → agentshim-0.13.0}/.gitignore +0 -0
  13. {agentshim-0.12.0 → agentshim-0.13.0}/README.md +0 -0
  14. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/_files.py +0 -0
  15. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/env.py +0 -0
  16. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/errors.py +0 -0
  17. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/events.py +0 -0
  18. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/mcp.py +0 -0
  19. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/pricing.py +0 -0
  20. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/schema.py +0 -0
  21. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/skills.py +0 -0
  22. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/stream.py +0 -0
  23. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/turn.py +0 -0
  24. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/core/usage.py +0 -0
  25. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/execution/__init__.py +0 -0
  26. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/execution/executor.py +0 -0
  27. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/execution/host.py +0 -0
  28. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/execution/transform.py +0 -0
  29. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/__init__.py +0 -0
  30. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/__init__.py +0 -0
  31. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/events.py +0 -0
  32. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/failures.py +0 -0
  33. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  34. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  35. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/parser.py +0 -0
  36. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/sandbox.py +0 -0
  37. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/scripted.py +0 -0
  38. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/claude/user_hooks.py +0 -0
  39. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/__init__.py +0 -0
  40. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/_toml.py +0 -0
  41. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/events.py +0 -0
  42. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/failures.py +0 -0
  43. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/mcp_scope.py +0 -0
  44. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/parser.py +0 -0
  45. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/rules.py +0 -0
  46. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/sandbox.py +0 -0
  47. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/scripted.py +0 -0
  48. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/codex/skills.py +0 -0
  49. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/copilot/__init__.py +0 -0
  50. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/copilot/events.py +0 -0
  51. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/copilot/parser.py +0 -0
  52. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/copilot/provider.py +0 -0
  53. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/copilot/scripted.py +0 -0
  54. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/gemini/__init__.py +0 -0
  55. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/gemini/events.py +0 -0
  56. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/gemini/parser.py +0 -0
  57. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/gemini/provider.py +0 -0
  58. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/gemini/scripted.py +0 -0
  59. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/opencode/__init__.py +0 -0
  60. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/opencode/events.py +0 -0
  61. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/opencode/parser.py +0 -0
  62. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/opencode/provider.py +0 -0
  63. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/providers/opencode/scripted.py +0 -0
  64. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/py.typed +0 -0
  65. {agentshim-0.12.0 → agentshim-0.13.0}/agentshim/testing/__init__.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 (2026-10-03)
4
+
5
+ A session can leave out the user's own CLI configuration. Additive.
6
+
7
+ ### Added
8
+
9
+ - `ConfigScope` (`ALL`, `PROJECT`) as a session option on `start_session` and
10
+ `run`, `ProviderProfile.config_scopes`, and `ArgvContext.config_scope`.
11
+ `PROJECT` keeps the user's settings, hooks, global instructions, notify
12
+ commands, profiles and memory out of every turn. Claude Code:
13
+ `--setting-sources project,local` and `autoMemoryEnabled=false`. Codex: a
14
+ dedicated `CODEX_HOME` plus `--ignore-user-config --disable memories`;
15
+ `build_argv` refuses the scope in the user's own home. Copilot, Gemini and
16
+ opencode refuse it.
17
+ - `ProviderProfile.config_home_files` and `prepare_config_home`, which build
18
+ that dedicated home holding only the login and return the environment that
19
+ points the CLI at it. The login is a symlink to the user's own `auth.json`,
20
+ so a refresh token Codex rotates in either home stays valid in both.
21
+
3
22
  ## 0.12.0 (2026-10-03)
4
23
 
5
24
  A turn whose output never matched its schema says so. Additive except that
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: tomli>=2; python_full_version < '3.11'
@@ -19,6 +19,7 @@ from .core import (
19
19
  CliTimeoutError,
20
20
  CompositeEventHandler,
21
21
  ConfigFileInstallation,
22
+ ConfigScope,
22
23
  ConsoleEventHandler,
23
24
  EventHandlerBase,
24
25
  FailureKind,
@@ -79,6 +80,7 @@ from .core import (
79
80
  normalize,
80
81
  normalized_usage,
81
82
  parse_json_object,
83
+ prepare_config_home,
82
84
  price_for,
83
85
  )
84
86
  from .execution import (
@@ -99,7 +101,7 @@ from .providers.copilot import CopilotProvider
99
101
  from .providers.gemini import GeminiProvider
100
102
  from .providers.opencode import OpencodeProvider
101
103
 
102
- __version__ = "0.12.0"
104
+ __version__ = "0.13.0"
103
105
 
104
106
  __all__ = [
105
107
  "AgentEvent",
@@ -125,6 +127,7 @@ __all__ = [
125
127
  "CommandStreamSink",
126
128
  "CompositeEventHandler",
127
129
  "ConfigFileInstallation",
130
+ "ConfigScope",
128
131
  "ConsoleEventHandler",
129
132
  "CopilotProvider",
130
133
  "EventHandlerBase",
@@ -194,6 +197,7 @@ __all__ = [
194
197
  "normalize",
195
198
  "normalized_usage",
196
199
  "parse_json_object",
200
+ "prepare_config_home",
197
201
  "price_for",
198
202
  "provider_names",
199
203
  ]
@@ -24,6 +24,7 @@ from agentshim.core.errors import (
24
24
  )
25
25
  from agentshim.core.events import RunFinished, RunStarted, compose_event_handlers
26
26
  from agentshim.core.profile import (
27
+ ConfigScope,
27
28
  McpMechanism,
28
29
  McpScope,
29
30
  OutputSchemaStyle,
@@ -114,7 +115,7 @@ class CliAgent:
114
115
  self.executor.check_binary(self.binary_path, self.env, timeout=check_timeout)
115
116
  self.log(f"{self.profile.display_name} ready at {self.binary_path}")
116
117
 
117
- def start_session(
118
+ def start_session( # noqa: PLR0913 - one keyword per independent session option
118
119
  self,
119
120
  *,
120
121
  cwd: str | None = None,
@@ -122,11 +123,12 @@ class CliAgent:
122
123
  session_id: str | None = None,
123
124
  skill_scope: SkillScope = SkillScope.ALL,
124
125
  mcp_scope: McpScope = McpScope.ALL,
126
+ config_scope: ConfigScope = ConfigScope.ALL,
125
127
  ) -> AgentSession:
126
128
  """Open a conversation whose turns resume one another.
127
129
 
128
- ``skill_scope`` and ``mcp_scope`` are fixed for the conversation (see
129
- ``AgentSession``).
130
+ ``skill_scope``, ``mcp_scope`` and ``config_scope`` are fixed for the
131
+ conversation (see ``AgentSession``).
130
132
  """
131
133
  return AgentSession(
132
134
  self,
@@ -135,9 +137,10 @@ class CliAgent:
135
137
  session_id=session_id,
136
138
  skill_scope=skill_scope,
137
139
  mcp_scope=mcp_scope,
140
+ config_scope=config_scope,
138
141
  )
139
142
 
140
- def run(
143
+ def run( # noqa: PLR0913 - one keyword per independent session option
141
144
  self,
142
145
  request: TurnRequest | str,
143
146
  *,
@@ -145,10 +148,15 @@ class CliAgent:
145
148
  timeout: float | None = None,
146
149
  skill_scope: SkillScope = SkillScope.ALL,
147
150
  mcp_scope: McpScope = McpScope.ALL,
151
+ config_scope: ConfigScope = ConfigScope.ALL,
148
152
  ) -> TurnResult:
149
153
  """Run one turn in a throwaway session."""
150
154
  session = self.start_session(
151
- cwd=cwd, timeout=timeout, skill_scope=skill_scope, mcp_scope=mcp_scope
155
+ cwd=cwd,
156
+ timeout=timeout,
157
+ skill_scope=skill_scope,
158
+ mcp_scope=mcp_scope,
159
+ config_scope=config_scope,
152
160
  )
153
161
  return session.turn(request)
154
162
 
@@ -169,6 +177,7 @@ class AgentSession:
169
177
  session_id: str | None = None,
170
178
  skill_scope: SkillScope = SkillScope.ALL,
171
179
  mcp_scope: McpScope = McpScope.ALL,
180
+ config_scope: ConfigScope = ConfigScope.ALL,
172
181
  ) -> None:
173
182
  """Bind a conversation to one agent, with per-turn defaults.
174
183
 
@@ -183,6 +192,11 @@ class AgentSession:
183
192
  limits which MCP servers every turn's CLI may connect to
184
193
  (``McpScope.SESSION``: only those in ``TurnRequest.mcp_servers``) and
185
194
  is checked against ``profile.mcp_scopes`` the same way.
195
+ ``config_scope`` limits which of the user's own CLI configuration
196
+ (settings, hooks, global instructions, memory) every turn loads, and
197
+ is checked against ``profile.config_scopes``; a provider with
198
+ ``profile.config_home_files`` additionally needs its state root
199
+ pointed at a home from ``prepare_config_home``.
186
200
  """
187
201
  if skill_scope not in agent.profile.skill_scopes:
188
202
  msg = f"{agent.profile.name} cannot limit skills to scope {skill_scope.value!r}"
@@ -190,7 +204,11 @@ class AgentSession:
190
204
  if mcp_scope not in agent.profile.mcp_scopes:
191
205
  msg = f"{agent.profile.name} cannot limit MCP servers to scope {mcp_scope.value!r}"
192
206
  raise ProviderCapabilityError(msg)
207
+ if config_scope not in agent.profile.config_scopes:
208
+ msg = f"{agent.profile.name} cannot limit user configuration to scope {config_scope.value!r}"
209
+ raise ProviderCapabilityError(msg)
193
210
  self.skill_scope = skill_scope
211
+ self.config_scope = config_scope
194
212
  self.mcp_scope = mcp_scope
195
213
  self._agent = agent
196
214
  self._cwd = cwd
@@ -302,6 +320,7 @@ class AgentSession:
302
320
  cwd=cwd,
303
321
  skill_scope=self.skill_scope,
304
322
  mcp_scope=self.mcp_scope,
323
+ config_scope=self.config_scope,
305
324
  mcp_servers=tuple(req.mcp_servers),
306
325
  )
307
326
  )
@@ -43,6 +43,7 @@ from .events import (
43
43
  UsageReport,
44
44
  compose_event_handlers,
45
45
  )
46
+ from .home import prepare_config_home
46
47
  from .mcp import (
47
48
  ConfigFileInstallation,
48
49
  FlagsInstallation,
@@ -55,6 +56,7 @@ from .mcp import (
55
56
  )
56
57
  from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
57
58
  from .profile import (
59
+ ConfigScope,
58
60
  McpMechanism,
59
61
  McpScope,
60
62
  OutputSchemaStyle,
@@ -82,6 +84,7 @@ __all__ = [
82
84
  "CliTimeoutError",
83
85
  "CompositeEventHandler",
84
86
  "ConfigFileInstallation",
87
+ "ConfigScope",
85
88
  "ConsoleEventHandler",
86
89
  "EventHandlerBase",
87
90
  "FailureKind",
@@ -142,5 +145,6 @@ __all__ = [
142
145
  "normalize",
143
146
  "normalized_usage",
144
147
  "parse_json_object",
148
+ "prepare_config_home",
145
149
  "price_for",
146
150
  ]
@@ -0,0 +1,90 @@
1
+ """Dedicated CLI homes for ``ConfigScope.PROJECT``.
2
+
3
+ Some CLIs read the user's global instructions, hooks and memory from their
4
+ state root (Codex: ``$CODEX_HOME/AGENTS.md``, ``hooks.json``, ``memories/``)
5
+ with no flag to skip them. The only way to keep them out is to run the CLI
6
+ against another state root that holds nothing but the credentials. That root
7
+ is the caller's to own, because the CLI keeps its conversations there too: a
8
+ session resumes only from the home it started in.
9
+
10
+ The credentials are linked, not copied. Codex rotates its OAuth refresh token
11
+ on refresh (the server rejects a reused one with ``refresh_token_reused``) and
12
+ saves ``auth.json`` by opening it with ``O_TRUNC`` and writing in place
13
+ (codex-rs ``login/src/auth/storage.rs``, ``FileAuthStorage::save``), which
14
+ follows a symlink. A copy would strand one of the two logins after the first
15
+ refresh; a link keeps a single file that every process rereads before it
16
+ refreshes (``AuthManager::refresh_token``'s guarded reload).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from ._files import real_path
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping
29
+
30
+ from .profile import ProviderProfile
31
+
32
+
33
+ def state_root(profile: ProviderProfile, env: Mapping[str, str]) -> Path | None:
34
+ """The state root the CLI run with *env* reads, or ``None`` if it has none.
35
+
36
+ ``$<state_root_env>`` when set, else ``$HOME/<state_dirs[0]>``.
37
+ """
38
+ if profile.state_root_env and env.get(profile.state_root_env):
39
+ return Path(env[profile.state_root_env]).expanduser()
40
+ if not profile.state_dirs:
41
+ return None
42
+ home = env.get("HOME") or os.path.expanduser("~") # noqa: PTH111 - a str contract, not a Path
43
+ return Path(home) / profile.state_dirs[0]
44
+
45
+
46
+ def prepare_config_home(
47
+ profile: ProviderProfile, home: Path, env: Mapping[str, str]
48
+ ) -> dict[str, str]:
49
+ """Make *home* a state root holding only the credentials, and point at it.
50
+
51
+ Links each of ``profile.config_home_files`` that exists in the state root
52
+ *env* selects into *home* as a symlink to that file's real path (replacing
53
+ whatever was there, such as a copy from an older agentshim), so a token
54
+ the CLI refreshes in either home is the one both read next. Returns the
55
+ environment overrides that make the CLI use *home*. A sandbox that runs
56
+ the CLI must expose the link's target read-write. Everything else in *home* (conversations, caches) is the
57
+ CLI's own and is left alone. A provider with no ``config_home_files``
58
+ isolates by flags alone: nothing is written and the result is empty.
59
+
60
+ Raises:
61
+ ValueError: *home* is not absolute, or is the state root *env*
62
+ already selects, which would isolate nothing.
63
+ """
64
+ if not profile.config_home_files:
65
+ return {}
66
+ if not home.is_absolute():
67
+ msg = f"{profile.name} config home must be an absolute path, got {home}"
68
+ raise ValueError(msg)
69
+ assert profile.state_root_env is not None, "config_home_files needs a state_root_env" # noqa: S101 - profile invariant
70
+ source = state_root(profile, env)
71
+ if source is not None and real_path(source) == real_path(home):
72
+ msg = f"{profile.name} config home {home} is the user's own state root"
73
+ raise ValueError(msg)
74
+ home.mkdir(parents=True, exist_ok=True)
75
+ for name in profile.config_home_files:
76
+ origin = None if source is None else source / name
77
+ if origin is not None and origin.is_file():
78
+ _link(home / name, real_path(origin))
79
+ return {profile.state_root_env: str(home)}
80
+
81
+
82
+ def _link(link: Path, target: Path) -> None:
83
+ """Point *link* at *target*, atomically replacing any file already there."""
84
+ if link.is_symlink() and link.readlink() == target:
85
+ return
86
+ link.parent.mkdir(parents=True, exist_ok=True)
87
+ staged = link.with_name(f".{link.name}.{os.getpid()}.link")
88
+ staged.unlink(missing_ok=True)
89
+ staged.symlink_to(target)
90
+ staged.replace(link)
@@ -93,6 +93,26 @@ class McpScope(Enum):
93
93
  SESSION = "session"
94
94
 
95
95
 
96
+ class ConfigScope(Enum):
97
+ """Which of the user's own CLI configuration a session loads.
98
+
99
+ ``ALL``: whatever the CLI loads by default: the user's settings, hooks,
100
+ global instructions (``~/.claude/CLAUDE.md``, ``$CODEX_HOME/AGENTS.md``),
101
+ notify commands, profiles and memory. ``PROJECT``: none of that; the
102
+ session sees the workspace's own instructions and settings plus what
103
+ agentshim passes for the turn, so the user running agentshim does not
104
+ change what the agent does. Credentials keep working. A provider that
105
+ lists a ``ProviderProfile.config_home_files`` can only isolate through a
106
+ dedicated home: ``prepare_config_home`` builds it and returns the
107
+ environment that points the CLI at it. A provider declares the scopes it
108
+ can enforce in ``ProviderProfile.config_scopes``; asking for another one
109
+ is an error, never a silent ``ALL``.
110
+ """
111
+
112
+ ALL = "all"
113
+ PROJECT = "project"
114
+
115
+
96
116
  def _no_container_env() -> Mapping[str, str]:
97
117
  """Empty read-only default: a frozen spec must not carry a mutable one.
98
118
 
@@ -145,3 +165,10 @@ class ProviderProfile:
145
165
  skill_scopes: frozenset[SkillScope] = frozenset({SkillScope.ALL})
146
166
  #: The ``McpScope`` values a session on this provider may request.
147
167
  mcp_scopes: frozenset[McpScope] = frozenset({McpScope.ALL})
168
+ #: The ``ConfigScope`` values a session on this provider may request.
169
+ config_scopes: frozenset[ConfigScope] = frozenset({ConfigScope.ALL})
170
+ #: Files, relative to the state root (``state_dirs[0]``, or
171
+ #: ``$<state_root_env>``), that ``prepare_config_home`` copies into a
172
+ #: dedicated home for ``ConfigScope.PROJECT``. Empty when the provider
173
+ #: isolates by flags alone and needs no home.
174
+ config_home_files: tuple[str, ...] = ()
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  from typing import TYPE_CHECKING, Any, Protocol
7
7
 
8
8
  from .errors import FailureKind
9
- from .profile import McpScope, SkillScope
9
+ from .profile import ConfigScope, McpScope, SkillScope
10
10
  from .usage import ProviderUsage
11
11
 
12
12
  if TYPE_CHECKING:
@@ -49,6 +49,9 @@ class ArgvContext:
49
49
  #: A provider enforcing ``McpScope.SESSION`` renders them itself when its
50
50
  #: installed config file is not enough to exclude the user's own.
51
51
  mcp_servers: Sequence[McpServer] = ()
52
+ #: Which of the user's own CLI configuration the CLI may load; the
53
+ #: session has already checked that the provider's profile supports it.
54
+ config_scope: ConfigScope = ConfigScope.ALL
52
55
 
53
56
 
54
57
  @dataclass(frozen=True)
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
8
8
  from agentshim.core.errors import FailureKind, ProviderCapabilityError, SessionResumeError
9
9
  from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
10
10
  from agentshim.core.profile import (
11
+ ConfigScope,
11
12
  McpMechanism,
12
13
  McpScope,
13
14
  OutputSchemaStyle,
@@ -44,6 +45,14 @@ MCP_SERVER_KEY = "mcpServers"
44
45
  #: account connectors are not user settings and stay.
45
46
  PROJECT_SETTING_SOURCES = ("--setting-sources", "project,local")
46
47
 
48
+ #: ``ConfigScope.PROJECT``: the same ``--setting-sources`` drops the user's
49
+ #: settings (hooks, ``env``, permissions, default model), ``~/.claude/CLAUDE.md``
50
+ #: and plugins; auto-memory is not a setting source and is switched off in the
51
+ #: turn's ``--settings`` instead, so ``~/.claude/projects/*/memory`` is neither
52
+ #: read nor written. Built-in skills, the workspace's ``CLAUDE.md``,
53
+ #: ``.claude/`` settings and skills, and credentials keep working.
54
+ NO_AUTO_MEMORY_SETTINGS = {"autoMemoryEnabled": False}
55
+
47
56
  #: ``McpScope.SESSION``: ``--strict-mcp-config`` makes ``--mcp-config`` the
48
57
  #: only source of MCP servers. It drops the user's and the project's
49
58
  #: (``.mcp.json``) servers, plugin servers and claude.ai account connectors,
@@ -75,6 +84,7 @@ PROFILE = ProviderProfile(
75
84
  skill_invocation=SkillSignal.STRUCTURED,
76
85
  skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
77
86
  mcp_scopes=frozenset({McpScope.ALL, McpScope.SESSION}),
87
+ config_scopes=frozenset({ConfigScope.ALL, ConfigScope.PROJECT}),
78
88
  container_install=(
79
89
  "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
80
90
  "curl -fsSL https://claude.ai/install.sh | bash",
@@ -138,7 +148,8 @@ class ClaudeProvider:
138
148
  "stream-json",
139
149
  "--verbose",
140
150
  ]
141
- if ctx.skill_scope is SkillScope.PROJECT:
151
+ isolate_config = ctx.config_scope is ConfigScope.PROJECT
152
+ if ctx.skill_scope is SkillScope.PROJECT or isolate_config:
142
153
  argv += PROJECT_SETTING_SOURCES
143
154
  if ctx.model:
144
155
  argv += ["--model", ctx.model]
@@ -147,6 +158,8 @@ class ClaudeProvider:
147
158
  if ctx.schema_inline:
148
159
  argv += ["--json-schema", ctx.schema_inline]
149
160
  settings = build_settings(self.sandbox, hooks=self.hooks)
161
+ if isolate_config:
162
+ settings.update(NO_AUTO_MEMORY_SETTINGS)
150
163
  if settings:
151
164
  argv += ["--settings", json.dumps(settings)]
152
165
  if ctx.mcp_scope is McpScope.SESSION:
@@ -7,8 +7,10 @@ import re
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
10
+ from agentshim.core.home import state_root
10
11
  from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
11
12
  from agentshim.core.profile import (
13
+ ConfigScope,
12
14
  McpMechanism,
13
15
  McpScope,
14
16
  OutputSchemaStyle,
@@ -85,6 +87,10 @@ PROFILE = ProviderProfile(
85
87
  skill_invocation=SkillSignal.INFERRED,
86
88
  skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
87
89
  mcp_scopes=frozenset({McpScope.ALL, McpScope.SESSION}),
90
+ config_scopes=frozenset({ConfigScope.ALL, ConfigScope.PROJECT}),
91
+ # Codex reads AGENTS.md, hooks.json and memories/ from $CODEX_HOME with no
92
+ # flag to skip them, so ConfigScope.PROJECT needs a home of its own.
93
+ config_home_files=("auth.json",),
88
94
  container_install=(_NODE_INSTALL, _CODEX_INSTALL),
89
95
  # Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
90
96
  # "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
@@ -105,6 +111,13 @@ BYPASS_FLAG = "--dangerously-bypass-approvals-and-sandbox"
105
111
  #: Keeps user and project ``.rules`` files out of a sandboxed turn.
106
112
  IGNORE_RULES_FLAG = "--ignore-rules"
107
113
 
114
+ #: ``ConfigScope.PROJECT``: ``$CODEX_HOME/config.toml`` (profiles, notify,
115
+ #: ``developer_instructions``, project trust) is not loaded, and the memories
116
+ #: Codex would otherwise write and reread across sessions in the home stay
117
+ #: off. The global ``AGENTS.md``, ``hooks.json`` and existing memories are
118
+ #: kept out by the dedicated home itself (``prepare_config_home``).
119
+ PROJECT_CONFIG_FLAGS = ("--ignore-user-config", "--disable", "memories")
120
+
108
121
 
109
122
  class CodexProvider:
110
123
  """Codex (``codex exec --json``). ``sandbox`` is a provider option."""
@@ -136,6 +149,9 @@ class CodexProvider:
136
149
  if ctx.model:
137
150
  argv += ["--model", ctx.model]
138
151
  argv += _shell_path_config(ctx.env)
152
+ if ctx.config_scope is ConfigScope.PROJECT:
153
+ _check_config_home(ctx)
154
+ argv += PROJECT_CONFIG_FLAGS
139
155
  argv += _scope_overrides(ctx)
140
156
  if ctx.reasoning_effort:
141
157
  argv += ["--config", f"model_reasoning_effort={toml_str(ctx.reasoning_effort)}"]
@@ -250,6 +266,26 @@ def _writable_dirs(config: CodexSandboxConfig, ctx: ArgvContext) -> list[str]:
250
266
  return dirs
251
267
 
252
268
 
269
+ def _check_config_home(ctx: ArgvContext) -> None:
270
+ """Refuse ``ConfigScope.PROJECT`` unless the turn runs in a home of its own.
271
+
272
+ The user's global ``AGENTS.md``, hooks and memories live in the state root
273
+ and no flag skips them, so a turn still pointed at the user's root would
274
+ load them while claiming to be isolated.
275
+ """
276
+ home = ctx.env.get("CODEX_HOME")
277
+ if not home or not os.path.isabs(home): # noqa: PTH117 - a str contract, not a Path
278
+ msg = (
279
+ "ConfigScope.PROJECT needs CODEX_HOME set to the absolute path of a "
280
+ f"dedicated home prepared with prepare_config_home (got {home!r})"
281
+ )
282
+ raise ProviderCapabilityError(msg)
283
+ user_root = state_root(PROFILE, {key: v for key, v in ctx.env.items() if key != "CODEX_HOME"})
284
+ if user_root is not None and os.path.realpath(home) == os.path.realpath(user_root):
285
+ msg = f"ConfigScope.PROJECT needs a dedicated CODEX_HOME, not the user's own {home}"
286
+ raise ProviderCapabilityError(msg)
287
+
288
+
253
289
  def _is_within(path: str, directory: str) -> bool:
254
290
  resolved, parent = os.path.realpath(path), os.path.realpath(directory)
255
291
  return resolved == parent or resolved.startswith(parent.rstrip(os.sep) + os.sep)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes