pcli-agent 0.1.0__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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,106 @@
1
+ """Autonomous memory extraction: after a compaction pass archives a chunk of
2
+ old conversation away (agent/compaction.py's maybe_compact), review that
3
+ transcript - plus what's already known - and classify anything worth
4
+ noting into one of remember's two scopes (tools/builtin/memory_tool.py) -
5
+ global (persisted, cross-session, cross-project) or local (not persisted
6
+ at all - a correctly-labeled dead end for project-specific content, not a
7
+ lesser fallback). Runs as a small, tool-only AgentLoop scoped to just that
8
+ one tool - no sandbox, shell, or file access needed or wanted here, unlike
9
+ a real subagent (tools/_nested_agent.py).
10
+
11
+ The explicit local category exists because leaving this as an implicit
12
+ binary "durable enough to remember, or not" judgment demonstrably failed in
13
+ practice: a real derived entry once got filed under the global 'preference'
14
+ category even though its content (a specific package's import error in one
15
+ project's test script) was clearly project-specific, not durable. Giving
16
+ the model a concrete, named place for "worth noting but not global" - the
17
+ same kind of discrete choice it already makes among the four global
18
+ categories - is the fix: classifying into a parallel category is a more
19
+ reliable task for an LLM than making an abstract counterfactual judgment
20
+ ("would this matter in some hypothetical future project?") inline while
21
+ also deciding whether to act at all.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import replace
27
+
28
+ from pcli.agent.loop import AgentLoop
29
+ from pcli.cost.tracker import cost_budget_reason
30
+ from pcli.llm.models import ChatMessage, Usage, UsageEvent
31
+ from pcli.memory.models import render_memory_section
32
+ from pcli.memory.store import read_memory
33
+ from pcli.tools.base import ToolContext
34
+ from pcli.tools.builtin.memory_tool import REMEMBER
35
+ from pcli.tools.registry import ToolRegistry
36
+
37
+ _EXTRACTION_SYSTEM_PROMPT = (
38
+ "You review a chunk of an in-progress coding-agent conversation and classify anything "
39
+ "worth noting into exactly one of two scopes. Be strict - most reviews should add zero "
40
+ "or one fact, not several.\n\n"
41
+ "GLOBAL - call remember(content, category) with category='profile' (their role or "
42
+ "domain), 'preference' (a recurring technical/workflow choice), 'style' (how they like "
43
+ "responses/conversation), or 'common_ask' (a task they repeatedly ask for across "
44
+ "different projects) ONLY for a fact that is true of the USER as a person, independent "
45
+ "of whatever project this conversation happens to be about. Before using one of these "
46
+ "four categories, check: would this sentence still make complete sense, with no "
47
+ "confusion, read cold at the start of a totally unrelated project with none of this "
48
+ "conversation's context? If no, it is not global - do not file it under one of these "
49
+ "four categories just because it seems important right now.\n\n"
50
+ "LOCAL - call remember(content, category='local') for anything that seems worth noting "
51
+ "but is specific to the current project, repo, file, or task: project/package names, "
52
+ "the specifics of a bug under investigation, a decision that only matters for this "
53
+ "codebase. This is the correct, intentional choice for most of what comes up in a "
54
+ "normal conversation - not a lesser fallback. Nothing saved this way is ever persisted "
55
+ "or shown again; it exists so you have somewhere correct to put project-specific "
56
+ "content instead of discarding it silently or stretching it to fit a global category.\n\n"
57
+ "If genuinely nothing in this chunk is worth noting even locally, make no calls at all."
58
+ )
59
+
60
+ _MAX_EXTRACTION_ITERATIONS = 5
61
+
62
+
63
+ async def extract_memory(transcript: str, ctx: ToolContext) -> list[Usage]:
64
+ """Reviews `transcript` (typically the plain-text rendering of the turns
65
+ a compaction pass just archived - see chat.py's _run_compaction) via a
66
+ tool-only AgentLoop scoped to REMEMBER, and returns the LLM usage
67
+ incurred so the caller can fold it into cost tracking. Never raises for
68
+ a "found nothing worth remembering" outcome - only for a real gateway
69
+ failure, which the caller is expected to catch (mirrors maybe_compact's
70
+ own GatewayError-can-propagate contract; ctx.gateway_client must already
71
+ be set, same precondition _run_compaction already checks before calling
72
+ maybe_compact in the first place)."""
73
+ assert ctx.gateway_client is not None
74
+
75
+ memory_registry = ToolRegistry()
76
+ memory_registry.register(REMEMBER)
77
+ extraction_ctx = replace(ctx, tool_registry=memory_registry)
78
+
79
+ known = render_memory_section(read_memory().entries) or "(nothing remembered yet)"
80
+ sub_loop = AgentLoop(
81
+ ctx.gateway_client,
82
+ model=ctx.model,
83
+ tool_registry=memory_registry,
84
+ permission_manager=ctx.permission_manager,
85
+ tool_context_factory=lambda: extraction_ctx,
86
+ max_tool_iterations=_MAX_EXTRACTION_ITERATIONS,
87
+ max_response_tokens=ctx.max_response_tokens,
88
+ temperature=ctx.temperature,
89
+ )
90
+ messages = [
91
+ ChatMessage(
92
+ role="system", content=f"{_EXTRACTION_SYSTEM_PROMPT}\n\nAlready known:\n{known}"
93
+ ),
94
+ ChatMessage(role="user", content=transcript),
95
+ ]
96
+
97
+ def _budget_check() -> str | None:
98
+ if ctx.session is None:
99
+ return None
100
+ return cost_budget_reason(ctx.session, ctx.max_session_cost_usd)
101
+
102
+ usages: list[Usage] = []
103
+ async for event in sub_loop.run_turn(messages, budget_check=_budget_check):
104
+ if isinstance(event, UsageEvent):
105
+ usages.append(event.usage)
106
+ return usages
pcli/memory/models.py ADDED
@@ -0,0 +1,103 @@
1
+ """Global, cross-session user-memory data model: what pcli has learned about
2
+ the user (nature of work, preferences, conversation style, recurring task
3
+ patterns) that should carry across every session, not just the one it was
4
+ learned in - see memory/store.py for persistence and memory/extraction.py
5
+ for how derived entries get added on top of the remember tool's explicit
6
+ ones (tools/builtin/memory_tool.py)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from pcli.util.ids import new_id
16
+
17
+ MemoryCategory = Literal["profile", "preference", "style", "common_ask", "local"]
18
+ """The first four are global - persisted to memory/store.py's cross-session,
19
+ cross-project store and injected into every future session's system prompt
20
+ (render_memory_section below). "local" is not one of those: it exists purely
21
+ so the LLM has an explicit, correctly-labeled place to put something
22
+ project/task-specific instead of being forced to either discard it or
23
+ miscategorize it as one of the global ones (the actual failure mode this
24
+ was added to fix - see tools/builtin/memory_tool.py's REMEMBER description
25
+ and memory/extraction.py's system prompt for the instructions that lean on
26
+ this distinction). A "local"-categorized MemoryEntry is never constructed in
27
+ practice - the remember tool's handler intercepts that category and returns
28
+ without calling add_entry - but it's kept as part of this same enum rather
29
+ than a second parallel type, since the tool's valid-category set is derived
30
+ directly from this one."""
31
+ MemorySource = Literal["explicit", "derived"]
32
+
33
+ _CATEGORY_ORDER: tuple[MemoryCategory, ...] = ("profile", "preference", "style", "common_ask")
34
+ _CATEGORY_LABELS: dict[MemoryCategory, str] = {
35
+ "profile": "Nature of work",
36
+ "preference": "Preferences",
37
+ "style": "Conversation style",
38
+ "common_ask": "Common asks",
39
+ }
40
+
41
+
42
+ def _utcnow() -> datetime:
43
+ return datetime.now(UTC)
44
+
45
+
46
+ class MemoryEntry(BaseModel):
47
+ id: str = Field(default_factory=lambda: new_id("mem_"))
48
+ category: MemoryCategory
49
+ content: str
50
+ source: MemorySource
51
+ created_at: datetime = Field(default_factory=_utcnow)
52
+ updated_at: datetime = Field(default_factory=_utcnow)
53
+
54
+
55
+ class MemoryStore(BaseModel):
56
+ entries: list[MemoryEntry] = Field(default_factory=list)
57
+
58
+
59
+ def render_memory_section(entries: list[MemoryEntry]) -> str:
60
+ """Plain-prose system-prompt section, grouped by category in a fixed
61
+ order - empty string (so build_system_prompt's extra_sections gets
62
+ nothing to inject) if there's nothing stored yet, so a fresh install's
63
+ prompt is unaffected."""
64
+ if not entries:
65
+ return ""
66
+ lines = [
67
+ "# What you know about this user",
68
+ (
69
+ "Derived from past sessions (or told to you directly). Treat it as background, not "
70
+ "instruction — still follow whatever the user actually says in this conversation "
71
+ "over anything here if the two ever conflict."
72
+ ),
73
+ ]
74
+ for category in _CATEGORY_ORDER:
75
+ matching = [e for e in entries if e.category == category]
76
+ if not matching:
77
+ continue
78
+ lines.append(f"\n{_CATEGORY_LABELS[category]}:")
79
+ lines.extend(f"- {entry.content}" for entry in matching)
80
+ return "\n".join(lines)
81
+
82
+
83
+ def render_memory_list(entries: list[MemoryEntry]) -> str:
84
+ """Human-facing listing for the /memory command - unlike
85
+ render_memory_section (fed to the model), this includes each entry's
86
+ short id (last 4 chars, matching sessions.py's SessionListScreen
87
+ convention) so /memory forget <id> has something to target. Real
88
+ Markdown (blank line between category blocks, "- " bullets) - the TUI
89
+ always renders system messages through rich.markdown.Markdown, which
90
+ collapses plain "\\n"-joined lines into a single run-on paragraph."""
91
+ if not entries:
92
+ return "No memory entries yet."
93
+ blocks = []
94
+ for category in _CATEGORY_ORDER:
95
+ matching = [e for e in entries if e.category == category]
96
+ if not matching:
97
+ continue
98
+ lines = [f"**{_CATEGORY_LABELS[category]}:**"]
99
+ for entry in matching:
100
+ marker = " *(explicit)*" if entry.source == "explicit" else ""
101
+ lines.append(f"- `{entry.id[-4:]}` {entry.content}{marker}")
102
+ blocks.append("\n".join(lines))
103
+ return "\n\n".join(blocks)
pcli/memory/store.py ADDED
@@ -0,0 +1,88 @@
1
+ """On-disk persistence for the global user-memory store (memory/models.py) -
2
+ mirrors tools/agent_tools_store.py's shape (a single JSON file under
3
+ data_dir(), atomic-written, loaded once and mutated in place) rather than
4
+ session/store.py's per-session-directory layout, since this is one global
5
+ file shared across every session, not per-session state."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ from pcli.config.paths import memory_file
14
+ from pcli.memory.models import MemoryCategory, MemoryEntry, MemorySource, MemoryStore
15
+
16
+
17
+ def _atomic_write(path: Path, content: str) -> None:
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
20
+ tmp_path.write_text(content, encoding="utf-8")
21
+ os.replace(tmp_path, path)
22
+
23
+
24
+ def read_memory() -> MemoryStore:
25
+ path = memory_file()
26
+ if not path.exists():
27
+ return MemoryStore()
28
+ return MemoryStore.model_validate_json(path.read_text(encoding="utf-8"))
29
+
30
+
31
+ def write_memory(store: MemoryStore) -> None:
32
+ _atomic_write(memory_file(), store.model_dump_json(indent=2))
33
+
34
+
35
+ def add_entry(
36
+ content: str,
37
+ *,
38
+ category: MemoryCategory,
39
+ source: MemorySource,
40
+ max_entries: int,
41
+ ) -> MemoryEntry:
42
+ """Appends a new entry, or - a deliberately simple, predictable check,
43
+ not fuzzy matching - refreshes an existing entry in the same category
44
+ whose content matches case-insensitively, rather than growing a pile of
45
+ near-duplicates every time the same fact gets re-derived. Evicts the
46
+ oldest source='derived' entry first (never 'explicit' - the user asked
47
+ for that one directly) once adding would exceed max_entries."""
48
+ store = read_memory()
49
+ content = content.strip()
50
+
51
+ for existing in store.entries:
52
+ if existing.category == category and existing.content.strip().lower() == content.lower():
53
+ existing.updated_at = datetime.now(UTC)
54
+ if source == "explicit":
55
+ existing.source = "explicit"
56
+ write_memory(store)
57
+ return existing
58
+
59
+ entry = MemoryEntry(category=category, content=content, source=source)
60
+ store.entries.append(entry)
61
+
62
+ while len(store.entries) > max_entries:
63
+ derived_indices = [i for i, e in enumerate(store.entries) if e.source == "derived"]
64
+ if not derived_indices:
65
+ break # every remaining entry is explicit - stop evicting rather than touch those
66
+ oldest = min(derived_indices, key=lambda i: store.entries[i].created_at)
67
+ del store.entries[oldest]
68
+
69
+ write_memory(store)
70
+ return entry
71
+
72
+
73
+ def remove_entry(entry_id: str) -> bool:
74
+ """Removes one entry by its full id. Returns whether anything was
75
+ removed - the /memory forget <id> command resolves a short (last-4-char)
76
+ id to a full one before calling this, so callers here always deal in
77
+ full ids, not the abbreviated form users type."""
78
+ store = read_memory()
79
+ before = len(store.entries)
80
+ store.entries = [e for e in store.entries if e.id != entry_id]
81
+ if len(store.entries) == before:
82
+ return False
83
+ write_memory(store)
84
+ return True
85
+
86
+
87
+ def clear_memory() -> None:
88
+ write_memory(MemoryStore())
File without changes
@@ -0,0 +1,219 @@
1
+ """Hard, non-negotiable, config-driven checks. A guardrail violation is an
2
+ automatic deny — it never reaches the permission-prompt UI. This is separate
3
+ from (and evaluated before) PermissionManager's ask/remember flow; see
4
+ manager.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from pcli.config.paths import guardrails_file
17
+ from pcli.config.settings import _dump_toml
18
+
19
+ DEFAULT_GUARDRAILS_TOML = """\
20
+ # pcli guardrails — hard limits that are never prompted, only enforced.
21
+ # Patterns without '*'/'?' are matched as a case-insensitive substring of the
22
+ # command; patterns with '*'/'?' are matched anywhere in the command via
23
+ # shell-style wildcards. This is a heuristic safety net, not a shell parser —
24
+ # treat it as defense-in-depth alongside the sandbox, not a substitute for it.
25
+
26
+ [shell]
27
+ denylist = [
28
+ "rm -rf /",
29
+ "rm -rf ~",
30
+ "mkfs*",
31
+ ":(){ :|:& };:",
32
+ "dd if=/dev/zero",
33
+ "> /dev/sda",
34
+ ]
35
+
36
+ [fs]
37
+ allowed_roots = ["."]
38
+ deny_paths = ["~/.ssh", "~/.aws", "~/.config/pcli"]
39
+
40
+ [limits]
41
+ max_output_bytes = 2000000
42
+ max_tool_calls_per_turn = 25
43
+ max_tool_calls_per_minute = 60
44
+ max_shell_timeout_s = 300
45
+ max_background_jobs = 5
46
+
47
+ [python]
48
+ # Top-level modules the pydiscovery tools (inspect_python_module, call_python)
49
+ # refuse to import/resolve into — these overlap with the dedicated, reviewed
50
+ # shell/fs tools, so letting the LLM reach them indirectly via arbitrary
51
+ # Python calls would be a redundant and higher-risk escape hatch.
52
+ module_denylist = [
53
+ "os",
54
+ "sys",
55
+ "subprocess",
56
+ "ctypes",
57
+ "shutil",
58
+ "socket",
59
+ "importlib",
60
+ "multiprocessing",
61
+ "threading",
62
+ "pty",
63
+ ]
64
+ """
65
+
66
+
67
+ class GuardrailResult(BaseModel):
68
+ allowed: bool
69
+ reason: str | None = None
70
+
71
+
72
+ def _pattern_matches(command: str, pattern: str) -> bool:
73
+ normalized_command = command.strip().lower()
74
+ normalized_pattern = pattern.strip().lower()
75
+ if "*" in normalized_pattern or "?" in normalized_pattern:
76
+ wrapped = normalized_pattern
77
+ if not wrapped.startswith("*"):
78
+ wrapped = f"*{wrapped}"
79
+ if not wrapped.endswith("*"):
80
+ wrapped = f"{wrapped}*"
81
+ return fnmatch.fnmatch(normalized_command, wrapped)
82
+ return normalized_pattern in normalized_command
83
+
84
+
85
+ class GuardrailsConfig(BaseModel):
86
+ shell_denylist: list[str] = []
87
+ fs_allowed_roots: list[str] = ["."]
88
+ fs_deny_paths: list[str] = []
89
+ max_output_bytes: int = 2_000_000
90
+ max_tool_calls_per_turn: int = 25
91
+ max_tool_calls_per_minute: int = 60
92
+ max_shell_timeout_s: int = 300
93
+ """Ceiling run_shell's (blocking) timeout_s is clamped to — commands
94
+ that need longer should use run_shell_background instead, which has no
95
+ such cap since it doesn't block the turn."""
96
+ max_background_jobs: int = 5
97
+ """Concurrent-running cap for run_shell_background; enforced by
98
+ RestrictedSubprocessSandbox.start_background."""
99
+ python_module_denylist: list[str] = []
100
+
101
+ @classmethod
102
+ def load(cls) -> GuardrailsConfig:
103
+ path = guardrails_file()
104
+ if not path.exists():
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_text(DEFAULT_GUARDRAILS_TOML, encoding="utf-8")
107
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
108
+ shell = raw.get("shell", {})
109
+ fs = raw.get("fs", {})
110
+ limits = raw.get("limits", {})
111
+ python = raw.get("python", {})
112
+ return cls(
113
+ shell_denylist=shell.get("denylist", []),
114
+ fs_allowed_roots=fs.get("allowed_roots", ["."]),
115
+ fs_deny_paths=fs.get("deny_paths", []),
116
+ max_output_bytes=limits.get("max_output_bytes", 2_000_000),
117
+ max_tool_calls_per_turn=limits.get("max_tool_calls_per_turn", 25),
118
+ max_tool_calls_per_minute=limits.get("max_tool_calls_per_minute", 60),
119
+ max_shell_timeout_s=limits.get("max_shell_timeout_s", 300),
120
+ max_background_jobs=limits.get("max_background_jobs", 5),
121
+ python_module_denylist=python.get("module_denylist", []),
122
+ )
123
+
124
+ def evaluate_command(self, command: str) -> GuardrailResult:
125
+ for pattern in self.shell_denylist:
126
+ if _pattern_matches(command, pattern):
127
+ return GuardrailResult(
128
+ allowed=False, reason=f"command matches denylist pattern '{pattern}'"
129
+ )
130
+ return GuardrailResult(allowed=True)
131
+
132
+ def evaluate_python_module(self, qualified_name: str) -> GuardrailResult:
133
+ top_level = qualified_name.split(".")[0]
134
+ if top_level in self.python_module_denylist:
135
+ return GuardrailResult(
136
+ allowed=False,
137
+ reason=f"module '{top_level}' is blocked (use the dedicated shell/fs tools instead)",
138
+ )
139
+ return GuardrailResult(allowed=True)
140
+
141
+ def evaluate_path(self, path: str | Path) -> GuardrailResult:
142
+ resolved = Path(path).expanduser().resolve()
143
+
144
+ for deny_path in self.fs_deny_paths:
145
+ deny_resolved = Path(deny_path).expanduser().resolve()
146
+ if resolved == deny_resolved or _is_relative_to(resolved, deny_resolved):
147
+ return GuardrailResult(
148
+ allowed=False,
149
+ reason=f"path is within denied path '{deny_path}'.\n"
150
+ "[pcli] Suggestion: this is a deliberate guardrails.toml [fs] deny_paths "
151
+ "entry (a hard block on a sensitive location - SSH keys, cloud "
152
+ "credentials, ...), checked before any permission prompt, so it can't be "
153
+ "approved by asking. If this is genuinely blocking legitimate work, that's "
154
+ "a guardrails.toml change for the user to make, not something to route "
155
+ "around",
156
+ )
157
+
158
+ for allowed_root in self.fs_allowed_roots:
159
+ allowed_resolved = Path(allowed_root).expanduser().resolve()
160
+ if resolved == allowed_resolved or _is_relative_to(resolved, allowed_resolved):
161
+ return GuardrailResult(allowed=True)
162
+
163
+ roots_list = ", ".join(
164
+ str(Path(root).expanduser()) for root in self.fs_allowed_roots
165
+ )
166
+ return GuardrailResult(
167
+ allowed=False,
168
+ reason=f"path '{resolved}' is outside all allowed roots ({roots_list}).\n"
169
+ "[pcli] Suggestion: this is a guardrail (guardrails.toml's [fs] allowed_roots), "
170
+ "checked before any permission prompt, so it can't be approved by asking - add "
171
+ "the path (or a parent of it) to allowed_roots to let the agent reach it (in the "
172
+ "TUI: /allowed-roots add <path>)",
173
+ )
174
+
175
+
176
+ def update_guardrails_limits(**limit_updates: Any) -> None:
177
+ """Persists the given key/value pairs into guardrails.toml's [limits]
178
+ table, preserving the [shell]/[fs]/[python] tables and any other
179
+ [limits] keys untouched — mirrors config/settings.py's
180
+ update_config_file, but for guardrails.toml's fixed 4-table shape
181
+ instead of config.toml's flatter one.
182
+
183
+ Falsy values (None) are skipped rather than written, matching
184
+ update_config_file's same behavior for optional callers."""
185
+ path = guardrails_file()
186
+ if path.exists():
187
+ raw = dict(tomllib.loads(path.read_text(encoding="utf-8")))
188
+ else:
189
+ raw = dict(tomllib.loads(DEFAULT_GUARDRAILS_TOML))
190
+ limits = dict(raw.get("limits", {}))
191
+ limits.update({key: value for key, value in limit_updates.items() if value is not None})
192
+ raw["limits"] = limits
193
+ path.write_text(_dump_toml(raw), encoding="utf-8")
194
+
195
+
196
+ def update_guardrails_fs_allowed_roots(allowed_roots: list[str]) -> None:
197
+ """Persists a full replacement allowed_roots list into guardrails.toml's
198
+ [fs] table, preserving deny_paths and every other table untouched -
199
+ same shape as update_guardrails_limits above but for a list-valued
200
+ [fs] key instead of a scalar [limits] one, so the caller (chat.py's
201
+ /allowed-roots add/remove) computes the whole new list itself rather
202
+ than this function doing partial add/remove logic."""
203
+ path = guardrails_file()
204
+ if path.exists():
205
+ raw = dict(tomllib.loads(path.read_text(encoding="utf-8")))
206
+ else:
207
+ raw = dict(tomllib.loads(DEFAULT_GUARDRAILS_TOML))
208
+ fs = dict(raw.get("fs", {}))
209
+ fs["allowed_roots"] = allowed_roots
210
+ raw["fs"] = fs
211
+ path.write_text(_dump_toml(raw), encoding="utf-8")
212
+
213
+
214
+ def _is_relative_to(path: Path, other: Path) -> bool:
215
+ try:
216
+ path.relative_to(other)
217
+ return True
218
+ except ValueError:
219
+ return False