langchain-agentx-python 1.3.0__py3-none-any.whl → 1.3.2__py3-none-any.whl
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.
- langchain_agentx/__init__.py +1 -1
- langchain_agentx/loop/graph/factory.py +20 -11
- langchain_agentx/memory/instruction/read_state_seeder.py +78 -0
- langchain_agentx/memory/instruction/runtime.py +35 -11
- langchain_agentx/plugin/loader.py +2 -1
- langchain_agentx/plugin/registries.py +30 -3
- langchain_agentx/session/agent_session.py +1 -1
- langchain_agentx/session/conversation_session.py +1 -1
- langchain_agentx/tool_runtime/__init__.py +6 -1
- langchain_agentx/tool_runtime/agent_home_bypass.py +231 -0
- langchain_agentx/tool_runtime/inner_permission_chain.py +8 -1
- langchain_agentx/tool_runtime/loop_loader.py +32 -5
- langchain_agentx/tool_runtime/path_safety.py +22 -3
- langchain_agentx/tool_runtime/policy.py +64 -8
- langchain_agentx/tool_runtime/read_ignore_patterns.py +61 -12
- langchain_agentx/tool_runtime/read_permission.py +41 -7
- langchain_agentx/tool_runtime/session_store.py +7 -0
- langchain_agentx/tools/edit/staleness.py +6 -61
- langchain_agentx/tools/edit/tool.py +4 -0
- langchain_agentx/tools/edit/validator.py +15 -6
- langchain_agentx/tools/glob/tool.py +4 -0
- langchain_agentx/tools/grep/tool.py +4 -0
- langchain_agentx/tools/read/tool.py +5 -0
- langchain_agentx/tools/shared/__init__.py +6 -0
- langchain_agentx/tools/shared/read_state_guard.py +55 -0
- langchain_agentx/tools/shared/staleness.py +70 -0
- langchain_agentx/tools/skill/dynamic_catalog.py +98 -0
- langchain_agentx/tools/skill/dynamic_discoverer.py +88 -0
- langchain_agentx/tools/skill/file_discovery.py +40 -0
- langchain_agentx/tools/skill/guards.py +44 -0
- langchain_agentx/tools/skill/loader.py +14 -2
- langchain_agentx/tools/skill/tool.py +15 -5
- langchain_agentx/tools/write/read_state_validator.py +69 -0
- langchain_agentx/tools/write/tool.py +73 -10
- langchain_agentx/tools/write/validator.py +6 -1
- langchain_agentx/utils/agent_settings.py +87 -3
- langchain_agentx/utils/claude_temp_paths.py +45 -5
- langchain_agentx/utils/file_mtime.py +27 -0
- langchain_agentx/utils/git/__init__.py +24 -0
- langchain_agentx/utils/git/gitignore.py +134 -0
- langchain_agentx/utils/permissions/__init__.py +5 -0
- langchain_agentx/utils/permissions/filesystem.py +58 -0
- {langchain_agentx_python-1.3.0.dist-info → langchain_agentx_python-1.3.2.dist-info}/METADATA +1 -1
- {langchain_agentx_python-1.3.0.dist-info → langchain_agentx_python-1.3.2.dist-info}/RECORD +47 -32
- {langchain_agentx_python-1.3.0.dist-info → langchain_agentx_python-1.3.2.dist-info}/LICENSE +0 -0
- {langchain_agentx_python-1.3.0.dist-info → langchain_agentx_python-1.3.2.dist-info}/WHEEL +0 -0
- {langchain_agentx_python-1.3.0.dist-info → langchain_agentx_python-1.3.2.dist-info}/top_level.txt +0 -0
langchain_agentx/__init__.py
CHANGED
|
@@ -502,19 +502,26 @@ class LoopGraphBuilder:
|
|
|
502
502
|
from ...memory.session import make_session_memory_section
|
|
503
503
|
|
|
504
504
|
self._dynamic_sections.append(make_session_memory_section(session_summary_path))
|
|
505
|
+
instruction_bootstrap = InstructionMemoryBootstrap(
|
|
506
|
+
workspace_root=Path(self._workspace_root),
|
|
507
|
+
agent_home_segment=self._agent_home_segment,
|
|
508
|
+
managed_rules=self._config.managed_rules,
|
|
509
|
+
claude_md_excludes=self._config.claude_md_excludes,
|
|
510
|
+
active_files_provider=(
|
|
511
|
+
CallbackActiveFilesProvider(self._services.active_files_getter)
|
|
512
|
+
if self._services.active_files_getter is not None
|
|
513
|
+
else None
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
loaded_instruction_rules = instruction_bootstrap.load_rule_set()
|
|
505
517
|
self._dynamic_sections.extend(
|
|
506
|
-
|
|
507
|
-
workspace_root=Path(self._workspace_root),
|
|
508
|
-
agent_home_segment=self._agent_home_segment,
|
|
509
|
-
managed_rules=self._config.managed_rules,
|
|
510
|
-
claude_md_excludes=self._config.claude_md_excludes,
|
|
511
|
-
active_files_provider=(
|
|
512
|
-
CallbackActiveFilesProvider(self._services.active_files_getter)
|
|
513
|
-
if self._services.active_files_getter is not None
|
|
514
|
-
else None
|
|
515
|
-
),
|
|
516
|
-
).build_sections()
|
|
518
|
+
instruction_bootstrap.build_sections(loaded_instruction_rules)
|
|
517
519
|
)
|
|
520
|
+
if session_store is not None:
|
|
521
|
+
instruction_bootstrap.seed_injected_read_state(
|
|
522
|
+
session_store,
|
|
523
|
+
loaded_instruction_rules,
|
|
524
|
+
)
|
|
518
525
|
self._dynamic_sections.extend(
|
|
519
526
|
MemoryPromptBootstrap(
|
|
520
527
|
workspace_root=Path(self._workspace_root),
|
|
@@ -772,6 +779,8 @@ class LoopGraphBuilder:
|
|
|
772
779
|
session_store=session_store,
|
|
773
780
|
schema_cache_namespace=schema_cache_namespace,
|
|
774
781
|
)
|
|
782
|
+
if self._loop_loader is not None:
|
|
783
|
+
self._loop_loader.refresh_search_ignore_patterns(session_store=session_store)
|
|
775
784
|
if self._runtime_tools and self._loop_loader is not None:
|
|
776
785
|
self._tool_prompt_ctx = self._build_tool_prompt_context(
|
|
777
786
|
session_store=session_store,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
memory/instruction/read_state_seeder.py — 指令注入 read-state 播种
|
|
3
|
+
|
|
4
|
+
职责:
|
|
5
|
+
将 Layer 1 规则文件注入 prompt 时同步写入 ``AgentSessionStore.file_read_state``;
|
|
6
|
+
注入内容与磁盘不一致时标记 ``is_partial_view``(对齐 CC attachments memoryFilesToAttachments)。
|
|
7
|
+
|
|
8
|
+
链路位置:
|
|
9
|
+
LoopGraphBuilder 初始化 → InstructionMemoryBootstrap.seed_injected_read_state()。
|
|
10
|
+
|
|
11
|
+
当前裁剪范围:
|
|
12
|
+
仅 Layer 1 ``LoadedRuleSet`` 文件路径;不含 memdir prefetch / session memory。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
from langchain_agentx.tool_runtime.session_store import AgentSessionStore
|
|
22
|
+
from langchain_agentx.utils.file_mtime import FileModificationTimeReader
|
|
23
|
+
|
|
24
|
+
from .loader import strip_html_comments
|
|
25
|
+
from .types import LoadedRuleSet, RuleFile
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InstructionReadStateSeeder:
|
|
29
|
+
"""指令规则注入时播种 last_read(partial view 语义)。"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, mtime_reader: FileModificationTimeReader | None = None) -> None:
|
|
32
|
+
self._mtime_reader = mtime_reader or FileModificationTimeReader()
|
|
33
|
+
|
|
34
|
+
def seed(self, session_store: AgentSessionStore, loaded: LoadedRuleSet) -> None:
|
|
35
|
+
for path, injected_content in self._iter_path_content_pairs(loaded):
|
|
36
|
+
self._seed_path(session_store, path, injected_content)
|
|
37
|
+
|
|
38
|
+
def _iter_path_content_pairs(self, loaded: LoadedRuleSet) -> Iterable[tuple[Path, str]]:
|
|
39
|
+
if loaded.user is not None and loaded.user.path is not None:
|
|
40
|
+
yield loaded.user.path, loaded.user.content
|
|
41
|
+
for rule_file in loaded.project_files:
|
|
42
|
+
if rule_file.path is not None:
|
|
43
|
+
yield rule_file.path, rule_file.content
|
|
44
|
+
if loaded.local is not None and loaded.local.path is not None:
|
|
45
|
+
yield loaded.local.path, loaded.local.content
|
|
46
|
+
for rule in loaded.conditional_rules:
|
|
47
|
+
yield rule.path, rule.content
|
|
48
|
+
|
|
49
|
+
def _seed_path(
|
|
50
|
+
self,
|
|
51
|
+
session_store: AgentSessionStore,
|
|
52
|
+
file_path: Path,
|
|
53
|
+
injected_content: str,
|
|
54
|
+
) -> None:
|
|
55
|
+
path = str(file_path.resolve())
|
|
56
|
+
if session_store.has_been_read(path):
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
raw_disk = file_path.read_text(encoding="utf-8")
|
|
60
|
+
except OSError:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
injected_view = strip_html_comments(injected_content)
|
|
64
|
+
content_differs = injected_view != raw_disk
|
|
65
|
+
mtime_ms = self._mtime_reader.read_ms(path)
|
|
66
|
+
entry: dict[str, object] = {
|
|
67
|
+
"tool_call_id": None,
|
|
68
|
+
"ts": time.time(),
|
|
69
|
+
"offset": None,
|
|
70
|
+
"limit": None,
|
|
71
|
+
"mtime_ms": mtime_ms,
|
|
72
|
+
"line_start": None,
|
|
73
|
+
"line_end": None,
|
|
74
|
+
"content": raw_disk if content_differs else injected_view,
|
|
75
|
+
}
|
|
76
|
+
if content_differs:
|
|
77
|
+
entry["is_partial_view"] = True
|
|
78
|
+
session_store.upsert_last_read_from_bridge_entry(path, entry)
|
|
@@ -15,13 +15,15 @@ from __future__ import annotations
|
|
|
15
15
|
import logging
|
|
16
16
|
from collections.abc import Callable, Sequence
|
|
17
17
|
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
18
19
|
|
|
19
20
|
from langchain_agentx.loop.prompt import SystemPromptSection
|
|
20
21
|
from langchain_agentx.workspace import resolve_agent_state_config
|
|
21
22
|
|
|
22
23
|
from .loader import load_rule_set
|
|
24
|
+
from .read_state_seeder import InstructionReadStateSeeder
|
|
23
25
|
from .sections import make_instruction_sections
|
|
24
|
-
from .types import InstructionMemoryConfig
|
|
26
|
+
from .types import InstructionMemoryConfig, LoadedRuleSet
|
|
25
27
|
|
|
26
28
|
logger = logging.getLogger(__name__)
|
|
27
29
|
|
|
@@ -43,31 +45,53 @@ class InstructionMemoryBootstrap:
|
|
|
43
45
|
self._include_depth_limit = include_depth_limit
|
|
44
46
|
self._claude_md_excludes = tuple(claude_md_excludes)
|
|
45
47
|
self._active_files_provider = active_files_provider or NullActiveFilesProvider()
|
|
48
|
+
self._read_state_seeder = InstructionReadStateSeeder()
|
|
46
49
|
|
|
47
|
-
def
|
|
50
|
+
def _build_config(self) -> InstructionMemoryConfig:
|
|
48
51
|
workspace_cfg = resolve_agent_state_config(
|
|
49
52
|
workspace_root=self._workspace_root,
|
|
50
53
|
agent_home_segment=self._agent_home_segment,
|
|
51
54
|
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
claude_md_excludes=self._claude_md_excludes,
|
|
59
|
-
)
|
|
55
|
+
return InstructionMemoryConfig(
|
|
56
|
+
workspace_cfg=workspace_cfg,
|
|
57
|
+
managed_rules=self._managed_rules,
|
|
58
|
+
load_user_layer=True,
|
|
59
|
+
include_depth_limit=self._include_depth_limit,
|
|
60
|
+
claude_md_excludes=self._claude_md_excludes,
|
|
60
61
|
)
|
|
62
|
+
|
|
63
|
+
def load_rule_set(self) -> LoadedRuleSet:
|
|
64
|
+
loaded_rules = load_rule_set(self._build_config())
|
|
61
65
|
if loaded_rules.external_include_warnings:
|
|
62
66
|
logger.warning(
|
|
63
67
|
"instruction_memory: external @path includes detected outside workspace: %s",
|
|
64
68
|
", ".join(loaded_rules.external_include_warnings),
|
|
65
69
|
)
|
|
70
|
+
return loaded_rules
|
|
71
|
+
|
|
72
|
+
def build_sections(
|
|
73
|
+
self,
|
|
74
|
+
loaded_rules: LoadedRuleSet | None = None,
|
|
75
|
+
) -> list[SystemPromptSection]:
|
|
76
|
+
rules = loaded_rules if loaded_rules is not None else self.load_rule_set()
|
|
66
77
|
return make_instruction_sections(
|
|
67
|
-
|
|
78
|
+
rules,
|
|
68
79
|
active_files_getter=self._active_files_provider.get_active_files,
|
|
69
80
|
)
|
|
70
81
|
|
|
82
|
+
def seed_injected_read_state(
|
|
83
|
+
self,
|
|
84
|
+
session_store: Any,
|
|
85
|
+
loaded_rules: LoadedRuleSet | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""注入 Layer 1 规则后播种 file_read_state(CC readFileState 对齐)。"""
|
|
88
|
+
from langchain_agentx.tool_runtime.session_store import AgentSessionStore
|
|
89
|
+
|
|
90
|
+
if not isinstance(session_store, AgentSessionStore):
|
|
91
|
+
return
|
|
92
|
+
rules = loaded_rules if loaded_rules is not None else self.load_rule_set()
|
|
93
|
+
self._read_state_seeder.seed(session_store, rules)
|
|
94
|
+
|
|
71
95
|
|
|
72
96
|
class ActiveFilesProvider:
|
|
73
97
|
"""active_files 提供器接口。"""
|
|
@@ -245,8 +245,9 @@ class PluginLoader:
|
|
|
245
245
|
|
|
246
246
|
async def _register_skills(self, plugins: list[LoadedPlugin]) -> list[PluginError]:
|
|
247
247
|
errors: list[PluginError] = []
|
|
248
|
+
cwd = self._cfg.workspace_root
|
|
248
249
|
for plugin in plugins:
|
|
249
|
-
errors.extend(self._skill_registry.register(plugin))
|
|
250
|
+
errors.extend(self._skill_registry.register(plugin, cwd=cwd))
|
|
250
251
|
return errors
|
|
251
252
|
|
|
252
253
|
async def _register_commands(self, plugins: list[LoadedPlugin]) -> list[PluginError]:
|
|
@@ -13,11 +13,16 @@ plugin/registries.py — Plugin 能力注册表(Skill/Command/Agent)。
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import logging
|
|
16
17
|
from dataclasses import dataclass
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
20
|
+
from langchain_agentx.tools.skill.guards import SkillsGitignoreGuard
|
|
21
|
+
|
|
19
22
|
from .types import ComponentLoadFailedError, LoadedPlugin, PluginError
|
|
20
23
|
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
@dataclass(frozen=True)
|
|
23
28
|
class SkillEntry:
|
|
@@ -43,15 +48,37 @@ class AgentDefinitionEntry:
|
|
|
43
48
|
|
|
44
49
|
|
|
45
50
|
class SkillRegistry:
|
|
46
|
-
def __init__(
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
workspace_root: str | Path | None = None,
|
|
55
|
+
gitignore_guard: SkillsGitignoreGuard | None = None,
|
|
56
|
+
) -> None:
|
|
47
57
|
self._skills: dict[str, SkillEntry] = {}
|
|
48
58
|
self._by_plugin: dict[str, list[str]] = {}
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
self._workspace_root = (
|
|
60
|
+
Path(workspace_root).resolve() if workspace_root is not None else None
|
|
61
|
+
)
|
|
62
|
+
self._gitignore_guard = gitignore_guard or SkillsGitignoreGuard()
|
|
63
|
+
|
|
64
|
+
def register(
|
|
65
|
+
self,
|
|
66
|
+
plugin: LoadedPlugin,
|
|
67
|
+
*,
|
|
68
|
+
cwd: str | Path | None = None,
|
|
69
|
+
) -> list[PluginError]:
|
|
51
70
|
if plugin.skills_dir is None or not plugin.skills_dir.exists():
|
|
52
71
|
self._by_plugin[plugin.source] = []
|
|
53
72
|
return []
|
|
54
73
|
|
|
74
|
+
work_cwd = cwd if cwd is not None else self._workspace_root
|
|
75
|
+
if work_cwd is not None and self._gitignore_guard.should_skip_skills_dir(
|
|
76
|
+
plugin.skills_dir,
|
|
77
|
+
cwd=work_cwd,
|
|
78
|
+
):
|
|
79
|
+
self._by_plugin[plugin.source] = []
|
|
80
|
+
return []
|
|
81
|
+
|
|
55
82
|
errors: list[PluginError] = []
|
|
56
83
|
registered: list[str] = []
|
|
57
84
|
for skill_dir in sorted(plugin.skills_dir.iterdir()):
|
|
@@ -97,7 +97,7 @@ class AgentSession:
|
|
|
97
97
|
resource_root=workspace_cfg.workspace_root,
|
|
98
98
|
agent_home_segment=workspace_cfg.agent_home_segment,
|
|
99
99
|
)
|
|
100
|
-
self._skill_registry = SkillRegistry()
|
|
100
|
+
self._skill_registry = SkillRegistry(workspace_root=workspace_cfg.workspace_root)
|
|
101
101
|
self._command_registry = PluginCommandRegistry()
|
|
102
102
|
self._agent_registry = AgentRegistry()
|
|
103
103
|
self._runtime_command_registry = CommandRegistry()
|
|
@@ -71,7 +71,7 @@ class ConversationSession:
|
|
|
71
71
|
self._container_type = "conversation"
|
|
72
72
|
self._session_memory_manager: Any | None = None
|
|
73
73
|
self._capabilities = capabilities
|
|
74
|
-
self._skill_registry = SkillRegistry()
|
|
74
|
+
self._skill_registry = SkillRegistry(workspace_root=workspace_cfg.workspace_root)
|
|
75
75
|
self._command_registry = PluginCommandRegistry()
|
|
76
76
|
self._agent_registry = AgentRegistry()
|
|
77
77
|
self._runtime_command_registry = CommandRegistry()
|
|
@@ -44,7 +44,11 @@ from .permission_snapshot import (
|
|
|
44
44
|
)
|
|
45
45
|
from .path_safety import PathSafetyConfig, PathSafetyEvaluator
|
|
46
46
|
from .policy import DefaultPolicyEngine, PolicyEngine, ToolPolicyConfig
|
|
47
|
-
from .read_ignore_patterns import
|
|
47
|
+
from .read_ignore_patterns import (
|
|
48
|
+
ReadDenyPatternCollector,
|
|
49
|
+
SearchIgnorePatternCollector,
|
|
50
|
+
normalize_read_deny_pattern,
|
|
51
|
+
)
|
|
48
52
|
from .policy_decorator import PolicyEngineDecorator
|
|
49
53
|
from .prompt import (
|
|
50
54
|
CliInteractivePolicyEngine,
|
|
@@ -124,6 +128,7 @@ __all__ = [
|
|
|
124
128
|
"PathSafetyConfig",
|
|
125
129
|
"PathSafetyEvaluator",
|
|
126
130
|
"ReadDenyPatternCollector",
|
|
131
|
+
"SearchIgnorePatternCollector",
|
|
127
132
|
"normalize_read_deny_pattern",
|
|
128
133
|
# prompt (L3)
|
|
129
134
|
"PermissionPromptHandler",
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent_home_bypass.py — session 级 agent_home 写 bypass(CC filesystem step 1.6)
|
|
3
|
+
|
|
4
|
+
职责:
|
|
5
|
+
仅 session 源 Edit allow 规则可绕过 path_safety 对 ``{segment}/`` 的 dangerous ask;
|
|
6
|
+
对齐 CC ``checkWritePermissionForTool`` 1.6 与 ``CLAUDE_FOLDER_PERMISSION_PATTERN``。
|
|
7
|
+
|
|
8
|
+
链路位置:
|
|
9
|
+
``DefaultPolicyEngine.evaluate_write_path_policy`` — L2/internal 之后、scratchpad allow、
|
|
10
|
+
``PathSafetyEvaluator`` 之前。
|
|
11
|
+
|
|
12
|
+
当前裁剪范围:
|
|
13
|
+
仅 ``Edit`` 工具;模式 ``/{segment}/**``、``~/{segment}/**``、
|
|
14
|
+
``/{segment}/skills/{name}/**``;不含 ``getClaudeSkillScope`` 建议生成(UI 层)。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import fnmatch
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from langchain_agentx.tool_runtime.models import AuthorizationDecision
|
|
27
|
+
from langchain_agentx.tool_runtime.permission_decision import (
|
|
28
|
+
attach_decision_reason,
|
|
29
|
+
make_rule_decision_reason,
|
|
30
|
+
)
|
|
31
|
+
from langchain_agentx.tool_runtime.permission_rules import (
|
|
32
|
+
PermissionRule,
|
|
33
|
+
PermissionRuleBehavior,
|
|
34
|
+
PermissionRuleSource,
|
|
35
|
+
PermissionRuleValue,
|
|
36
|
+
build_permission_rule_registry,
|
|
37
|
+
permission_rule_value_from_string,
|
|
38
|
+
)
|
|
39
|
+
from langchain_agentx.tool_runtime.tool_name_constants import Edit
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from .policy import ToolPolicyConfig
|
|
43
|
+
|
|
44
|
+
_SKILL_GLOB_META = re.compile(r"[*?[\]]")
|
|
45
|
+
_POLICY_ID = "agent_home_session_bypass"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_agent_home_segment(agent_home_segment: str | None) -> str:
|
|
49
|
+
seg = (agent_home_segment or ".langchain_agentx").strip()
|
|
50
|
+
if not seg.startswith("."):
|
|
51
|
+
seg = f".{seg}"
|
|
52
|
+
return seg
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def project_agent_home_folder_pattern(segment: str) -> str:
|
|
56
|
+
"""对齐 CC ``CLAUDE_FOLDER_PERMISSION_PATTERN``(参数化 segment)。"""
|
|
57
|
+
return f"/{segment}/**"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def global_agent_home_folder_pattern(segment: str) -> str:
|
|
61
|
+
"""对齐 CC ``GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN``。"""
|
|
62
|
+
return f"~/{segment}/**"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_valid_agent_home_session_allow_pattern(
|
|
66
|
+
rule_content: str,
|
|
67
|
+
*,
|
|
68
|
+
agent_home_segment: str,
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""
|
|
71
|
+
CC step 1.6 scope check:拒绝 ``..`` / 非 ``/**`` 结尾;窄化 skills 子路径。
|
|
72
|
+
"""
|
|
73
|
+
content = str(rule_content).strip()
|
|
74
|
+
if not content or ".." in content or not content.endswith("/**"):
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
segment = normalize_agent_home_segment(agent_home_segment)
|
|
78
|
+
folder_base = project_agent_home_folder_pattern(segment)[:-2] # ``/.segment/``
|
|
79
|
+
global_base = global_agent_home_folder_pattern(segment)[:-2] # ``~/.segment/``
|
|
80
|
+
|
|
81
|
+
if not (content.startswith(folder_base) or content.startswith(global_base)):
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if content in (project_agent_home_folder_pattern(segment), global_agent_home_folder_pattern(segment)):
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
for base in (folder_base, global_base):
|
|
88
|
+
skills_prefix = f"{base}skills/"
|
|
89
|
+
if content.startswith(skills_prefix):
|
|
90
|
+
skill_part = content[len(skills_prefix) : -3]
|
|
91
|
+
if not skill_part or skill_part == "." or ".." in skill_part:
|
|
92
|
+
return False
|
|
93
|
+
if _SKILL_GLOB_META.search(skill_part):
|
|
94
|
+
return False
|
|
95
|
+
return content == f"{skills_prefix}{skill_part}/**"
|
|
96
|
+
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_under(path: Path, base: Path) -> bool:
|
|
101
|
+
try:
|
|
102
|
+
path.resolve().relative_to(base.resolve())
|
|
103
|
+
return True
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def path_matches_agent_home_allow_pattern(
|
|
109
|
+
real_path: str,
|
|
110
|
+
rule_content: str,
|
|
111
|
+
*,
|
|
112
|
+
workspace_root: str | Path | None,
|
|
113
|
+
agent_home_segment: str,
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""路径是否命中 agent_home allow 规则(目录语义 + fnmatch 补充)。"""
|
|
116
|
+
content = str(rule_content).strip()
|
|
117
|
+
segment = normalize_agent_home_segment(agent_home_segment)
|
|
118
|
+
path = Path(real_path).resolve()
|
|
119
|
+
|
|
120
|
+
if content == project_agent_home_folder_pattern(segment):
|
|
121
|
+
if workspace_root is not None:
|
|
122
|
+
project_base = Path(workspace_root).resolve() / segment
|
|
123
|
+
if _is_under(path, project_base):
|
|
124
|
+
return True
|
|
125
|
+
return _is_under(path, Path.home().resolve() / segment)
|
|
126
|
+
|
|
127
|
+
if content == global_agent_home_folder_pattern(segment):
|
|
128
|
+
return _is_under(path, Path.home().resolve() / segment)
|
|
129
|
+
|
|
130
|
+
folder_base = f"/{segment}/skills/"
|
|
131
|
+
global_skills_base = f"~/{segment}/skills/"
|
|
132
|
+
skill_name: str | None = None
|
|
133
|
+
if content.startswith(folder_base) and content.endswith("/**"):
|
|
134
|
+
skill_name = content[len(folder_base) : -3]
|
|
135
|
+
elif content.startswith(global_skills_base) and content.endswith("/**"):
|
|
136
|
+
skill_name = content[len(global_skills_base) : -3]
|
|
137
|
+
|
|
138
|
+
if skill_name:
|
|
139
|
+
if workspace_root is not None:
|
|
140
|
+
skill_dir = Path(workspace_root).resolve() / segment / "skills" / skill_name
|
|
141
|
+
if _is_under(path, skill_dir):
|
|
142
|
+
return True
|
|
143
|
+
skill_dir = Path.home().resolve() / segment / "skills" / skill_name
|
|
144
|
+
return _is_under(path, skill_dir)
|
|
145
|
+
|
|
146
|
+
candidates = [real_path.replace("\\", "/")]
|
|
147
|
+
if workspace_root is not None:
|
|
148
|
+
try:
|
|
149
|
+
rel = os.path.relpath(real_path, str(workspace_root)).replace("\\", "/")
|
|
150
|
+
if not rel.startswith(".."):
|
|
151
|
+
candidates.append(f"/{rel}")
|
|
152
|
+
candidates.append(rel)
|
|
153
|
+
except ValueError:
|
|
154
|
+
pass
|
|
155
|
+
return any(fnmatch.fnmatch(c, content) for c in candidates)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class AgentHomeSessionBypassEvaluator:
|
|
160
|
+
"""session Edit allow → agent_home path_safety bypass(CC step 1.6)。"""
|
|
161
|
+
|
|
162
|
+
def evaluate(
|
|
163
|
+
self,
|
|
164
|
+
real_path: str,
|
|
165
|
+
*,
|
|
166
|
+
tool_name: str,
|
|
167
|
+
policy_config: ToolPolicyConfig | None,
|
|
168
|
+
session_store: Any | None = None,
|
|
169
|
+
workspace_root: str | Path | None = None,
|
|
170
|
+
agent_home_segment: str | None = None,
|
|
171
|
+
) -> AuthorizationDecision | None:
|
|
172
|
+
if tool_name != Edit:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
segment = normalize_agent_home_segment(agent_home_segment)
|
|
176
|
+
registry = build_permission_rule_registry(
|
|
177
|
+
policy_config=policy_config,
|
|
178
|
+
session_store=session_store,
|
|
179
|
+
workspace_root=workspace_root,
|
|
180
|
+
agent_home_segment=segment,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
session_rules = registry.allow.get(PermissionRuleSource.SESSION.value, ())
|
|
184
|
+
for rule_string in session_rules:
|
|
185
|
+
try:
|
|
186
|
+
value = permission_rule_value_from_string(rule_string)
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue
|
|
189
|
+
if value.tool_name != Edit or value.rule_content is None:
|
|
190
|
+
continue
|
|
191
|
+
content = value.rule_content
|
|
192
|
+
if not is_valid_agent_home_session_allow_pattern(
|
|
193
|
+
content,
|
|
194
|
+
agent_home_segment=segment,
|
|
195
|
+
):
|
|
196
|
+
continue
|
|
197
|
+
if not path_matches_agent_home_allow_pattern(
|
|
198
|
+
real_path,
|
|
199
|
+
content,
|
|
200
|
+
workspace_root=workspace_root,
|
|
201
|
+
agent_home_segment=segment,
|
|
202
|
+
):
|
|
203
|
+
continue
|
|
204
|
+
rule = PermissionRule(
|
|
205
|
+
source=PermissionRuleSource.SESSION,
|
|
206
|
+
rule_behavior=PermissionRuleBehavior.ALLOW,
|
|
207
|
+
rule_value=PermissionRuleValue(
|
|
208
|
+
tool_name=Edit,
|
|
209
|
+
rule_content=content,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
decision = AuthorizationDecision(
|
|
213
|
+
behavior="allow",
|
|
214
|
+
policy_id=_POLICY_ID,
|
|
215
|
+
message="Write allowed by session agent_home permission rule.",
|
|
216
|
+
)
|
|
217
|
+
return attach_decision_reason(
|
|
218
|
+
decision,
|
|
219
|
+
make_rule_decision_reason(rule, rule_kind="always_allow_tool"),
|
|
220
|
+
)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = [
|
|
225
|
+
"AgentHomeSessionBypassEvaluator",
|
|
226
|
+
"global_agent_home_folder_pattern",
|
|
227
|
+
"is_valid_agent_home_session_allow_pattern",
|
|
228
|
+
"normalize_agent_home_segment",
|
|
229
|
+
"path_matches_agent_home_allow_pattern",
|
|
230
|
+
"project_agent_home_folder_pattern",
|
|
231
|
+
]
|
|
@@ -383,6 +383,7 @@ class InnerPermissionChain:
|
|
|
383
383
|
wr = ctx.workspace_root
|
|
384
384
|
if wr is None and self._engine._config.project_memory_layout is not None:
|
|
385
385
|
wr = str(self._engine._config.project_memory_layout.project_root)
|
|
386
|
+
session_id, session_cwd = self._engine._session_context(ctx)
|
|
386
387
|
decision = self._engine._read_authorizer.authorize(
|
|
387
388
|
real_path,
|
|
388
389
|
tool_name=tool_name,
|
|
@@ -390,6 +391,8 @@ class InnerPermissionChain:
|
|
|
390
391
|
session_store=ctx.session_store,
|
|
391
392
|
workspace_root=wr,
|
|
392
393
|
agent_home_segment=ctx.agent_home_segment,
|
|
394
|
+
session_id=session_id,
|
|
395
|
+
session_cwd=session_cwd,
|
|
393
396
|
)
|
|
394
397
|
return PathPolicyResult(decision=decision)
|
|
395
398
|
|
|
@@ -401,7 +404,11 @@ class InnerPermissionChain:
|
|
|
401
404
|
),
|
|
402
405
|
)
|
|
403
406
|
|
|
404
|
-
return self._engine.evaluate_write_path_policy(
|
|
407
|
+
return self._engine.evaluate_write_path_policy(
|
|
408
|
+
real_path,
|
|
409
|
+
tool_name=tool_name,
|
|
410
|
+
ctx=ctx,
|
|
411
|
+
)
|
|
405
412
|
|
|
406
413
|
@staticmethod
|
|
407
414
|
def _segment_5_passthrough_promoted_ask(
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from .path_safety import PathSafetyConfig
|
|
21
21
|
from .process_loader import ProcessLoader
|
|
22
|
-
from .read_ignore_patterns import
|
|
22
|
+
from .read_ignore_patterns import SearchIgnorePatternCollector
|
|
23
23
|
from .registry import RuntimeToolRegistry
|
|
24
24
|
from .policy import DefaultPolicyEngine, ToolPolicyConfig
|
|
25
25
|
|
|
@@ -76,7 +76,7 @@ class LoopLoader:
|
|
|
76
76
|
from langchain_agentx.tools.bash import BashRuntimeTool
|
|
77
77
|
from langchain_agentx.tools.edit import EditRuntimeTool
|
|
78
78
|
from langchain_agentx.tools.edit.settings_validator import SettingsValidator
|
|
79
|
-
from langchain_agentx.tools.
|
|
79
|
+
from langchain_agentx.tools.shared.staleness import StalenessChecker
|
|
80
80
|
from langchain_agentx.tools.edit.validator import EditStringValidator
|
|
81
81
|
from langchain_agentx.tools.glob import GlobRuntimeTool
|
|
82
82
|
from langchain_agentx.tools.grep import GrepRuntimeTool
|
|
@@ -124,7 +124,12 @@ class LoopLoader:
|
|
|
124
124
|
)
|
|
125
125
|
|
|
126
126
|
self.register(ReadRuntimeTool(state_bridge=shared_bridge))
|
|
127
|
-
self.register(
|
|
127
|
+
self.register(
|
|
128
|
+
WriteRuntimeTool(
|
|
129
|
+
state_bridge=shared_bridge,
|
|
130
|
+
staleness_checker=edit_staleness,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
128
133
|
self.register(
|
|
129
134
|
EditRuntimeTool(
|
|
130
135
|
state_bridge=shared_bridge,
|
|
@@ -176,12 +181,34 @@ class LoopLoader:
|
|
|
176
181
|
|
|
177
182
|
return self
|
|
178
183
|
|
|
179
|
-
def _collect_search_ignore_patterns(
|
|
184
|
+
def _collect_search_ignore_patterns(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
session_store: Any | None = None,
|
|
188
|
+
) -> list[str]:
|
|
180
189
|
cfg = self._process.policy_config
|
|
181
190
|
policy = self._process.policy
|
|
182
191
|
if cfg is None and isinstance(policy, DefaultPolicyEngine):
|
|
183
192
|
cfg = policy._config
|
|
184
|
-
return
|
|
193
|
+
return SearchIgnorePatternCollector().collect(
|
|
194
|
+
cfg,
|
|
195
|
+
session_store=session_store,
|
|
196
|
+
workspace_root=self._workspace_root,
|
|
197
|
+
agent_home_segment=self._agent_home,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def refresh_search_ignore_patterns(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
session_store: Any | None = None,
|
|
204
|
+
) -> list[str]:
|
|
205
|
+
"""热刷新 Grep/Glob 的 ``ignore_patterns``(对齐 CC settings 变更后搜索隐藏)。"""
|
|
206
|
+
patterns = self._collect_search_ignore_patterns(session_store=session_store)
|
|
207
|
+
for tool in self._registry.list():
|
|
208
|
+
setter = getattr(tool, "set_ignore_patterns", None)
|
|
209
|
+
if callable(setter):
|
|
210
|
+
setter(patterns)
|
|
211
|
+
return patterns
|
|
185
212
|
|
|
186
213
|
def _inject_project_memory_policy(self, workspace_cfg: Any) -> None:
|
|
187
214
|
"""Per-loop 注入 CC projects 树读写根与 path_safety layout。"""
|