power-loop 5.2.2__tar.gz → 5.3.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 (105) hide show
  1. {power_loop-5.2.2 → power_loop-5.3.0}/PKG-INFO +1 -1
  2. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/__init__.py +1 -1
  3. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/interface.py +5 -0
  4. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/llm_factory.py +15 -2
  5. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/env.py +4 -0
  6. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/provider.py +1 -0
  7. power_loop-5.3.0/power_loop/tools/command_policy.py +201 -0
  8. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/tools/default_tools.py +7 -0
  9. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop.egg-info/PKG-INFO +1 -1
  10. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop.egg-info/SOURCES.txt +1 -0
  11. {power_loop-5.2.2 → power_loop-5.3.0}/LICENSE +0 -0
  12. {power_loop-5.2.2 → power_loop-5.3.0}/README.md +0 -0
  13. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/__init__.py +0 -0
  14. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  15. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  16. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  17. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  18. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  19. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  20. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/agent/__init__.py +0 -0
  21. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/agent/follow_up.py +0 -0
  22. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/agent/sink.py +0 -0
  23. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/agent/stateful_loop.py +0 -0
  24. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/agent/system_prompt.py +0 -0
  25. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/agent/types.py +0 -0
  26. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/__init__.py +0 -0
  27. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/errors.py +0 -0
  28. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/event_payloads.py +0 -0
  29. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/events.py +0 -0
  30. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/handlers.py +0 -0
  31. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/hook_contexts.py +0 -0
  32. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/hooks.py +0 -0
  33. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/messages.py +0 -0
  34. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/protocols.py +0 -0
  35. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contracts/tools.py +0 -0
  36. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/__init__.py +0 -0
  37. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/_redact.py +0 -0
  38. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/jsonl_sink.py +0 -0
  39. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/logging_sink.py +0 -0
  40. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/mcp.py +0 -0
  41. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/metrics_sink.py +0 -0
  42. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/contrib/otel_sink.py +0 -0
  43. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/agent_context.py +0 -0
  44. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/events.py +0 -0
  45. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/hooks.py +0 -0
  46. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/phase.py +0 -0
  47. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/pipeline.py +0 -0
  48. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/runner.py +0 -0
  49. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/core/state.py +0 -0
  50. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/py.typed +0 -0
  51. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/blackboard.py +0 -0
  52. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/budget.py +0 -0
  53. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/cancellation.py +0 -0
  54. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/compact.py +0 -0
  55. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/exec_backend.py +0 -0
  56. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/fold.py +0 -0
  57. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/fold_adapter.py +0 -0
  58. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/history_projector.py +0 -0
  59. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/history_sanitize.py +0 -0
  60. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/human_input.py +0 -0
  61. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/memory.py +0 -0
  62. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/notes.py +0 -0
  63. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/representation.py +0 -0
  64. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/retry.py +0 -0
  65. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/runtime_state.py +0 -0
  66. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/session_store.py +0 -0
  67. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/skills.py +0 -0
  68. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/spec.py +0 -0
  69. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/__init__.py +0 -0
  70. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  71. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  72. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  73. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  74. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/capabilities.py +0 -0
  75. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/db.py +0 -0
  76. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/dialect.py +0 -0
  77. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/factory.py +0 -0
  78. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/schema.py +0 -0
  79. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/store.py +0 -0
  80. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/store/types.py +0 -0
  81. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/structured.py +0 -0
  82. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/stub_provider.py +0 -0
  83. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/runtime/timers.py +0 -0
  84. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/tools/__init__.py +0 -0
  85. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/tools/blackboard.py +0 -0
  86. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/tools/default_manifest.py +0 -0
  87. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/tools/registry.py +0 -0
  88. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/tools/spawn_agent.py +0 -0
  89. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/__init__.py +0 -0
  90. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/api.py +0 -0
  91. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/engine.py +0 -0
  92. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/introspect.py +0 -0
  93. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/journal.py +0 -0
  94. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/result.py +0 -0
  95. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/resume.py +0 -0
  96. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/runner.py +0 -0
  97. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/spec.py +0 -0
  98. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/subprocess_executor.py +0 -0
  99. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/tool.py +0 -0
  100. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop/workflow/worker.py +0 -0
  101. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop.egg-info/dependency_links.txt +0 -0
  102. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop.egg-info/requires.txt +0 -0
  103. {power_loop-5.2.2 → power_loop-5.3.0}/power_loop.egg-info/top_level.txt +0 -0
  104. {power_loop-5.2.2 → power_loop-5.3.0}/pyproject.toml +0 -0
  105. {power_loop-5.2.2 → power_loop-5.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 5.2.2
3
+ Version: 5.3.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "5.2.2"
18
+ __version__ = "5.3.0"
19
19
 
20
20
  # Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
21
21
  # a custom LLMService) don't reach into the internal vendored transport package (H3.4).
@@ -467,6 +467,11 @@ class OpenAICompatibleChatConfig:
467
467
  stream_max_restarts: int = 0
468
468
  stream_resume_instruction: str = "继续,从你上次中断的位置继续输出。不要重复已经输出的内容。"
469
469
  capability_overrides: dict[str, Any] = field(default_factory=dict)
470
+ # Config-level request kwargs merged under every request's ``extra`` (request wins; nested
471
+ # ``extra_body`` dicts are merged key-by-key). This is how a host turns a per-model switch
472
+ # (e.g. DashScope ``extra_body.enable_thinking``) on for a whole loop without touching the
473
+ # pipeline's per-call LLMRequest.
474
+ request_extra: dict[str, Any] = field(default_factory=dict)
470
475
 
471
476
  @property
472
477
  def is_ready(self) -> bool:
@@ -82,6 +82,19 @@ def _format_messages_for_debug(messages: Sequence[dict[str, Any]]) -> str:
82
82
 
83
83
 
84
84
 
85
+ def merge_request_extra(base: dict[str, Any] | None, override: dict[str, Any] | None) -> dict[str, Any]:
86
+ """Config-level ``request_extra`` under a request's ``extra`` (request wins; ``extra_body`` merged)."""
87
+ out: dict[str, Any] = dict(base or {})
88
+ for k, v in dict(override or {}).items():
89
+ if k == "extra_body" and isinstance(v, dict) and isinstance(out.get("extra_body"), dict):
90
+ out["extra_body"] = {**out["extra_body"], **v}
91
+ else:
92
+ out[k] = v
93
+ if isinstance(out.get("extra_body"), dict):
94
+ out["extra_body"] = dict(out["extra_body"])
95
+ return out
96
+
97
+
85
98
  class OpenAICompatibleChatLLMService(LLMService):
86
99
  """
87
100
  Minimal OpenAI-compatible chat completion client.
@@ -445,12 +458,12 @@ class OpenAICompatibleChatLLMService(LLMService):
445
458
  await asyncio.sleep(delay)
446
459
  raise cast(Exception, last_err)
447
460
 
448
- def _request_kwargs(self, request: LLMRequest) -> dict[str, Any]:
461
+ def _request_kwargs(self, request: LLMRequest) -> dict[str, Any]: # noqa: C901
449
462
  """
450
463
  Map `LLMRequest` into kwargs for OpenAI-compatible chat.completions.create.
451
464
  Falls back to self._cfg for default settings.
452
465
  """
453
- out: dict[str, Any] = dict(request.extra or {})
466
+ out: dict[str, Any] = merge_request_extra(getattr(self._cfg, "request_extra", None), request.extra)
454
467
  out["model"] = request.model if request.model else self._cfg.model
455
468
 
456
469
  req_temp = request.temperature if request.temperature is not None else self._cfg.temperature
@@ -35,6 +35,10 @@ class RuntimeEnv:
35
35
  # conversation-scoped / cross-process board. See runtime.blackboard.
36
36
  blackboard: Blackboard | None = None
37
37
  blackboard_id: str | None = None
38
+ # Command categories the host forbids for `bash` / `background_run` (see
39
+ # tools.command_policy): "package_install" | "download" | "daemon". "pipe_to_shell"
40
+ # is always blocked. Empty (default) = only the always-blocked set applies.
41
+ blocked_command_categories: frozenset[str] = frozenset()
38
42
 
39
43
  @classmethod
40
44
  def from_env(
@@ -167,6 +167,7 @@ class LLMProviderConfig:
167
167
  temperature=self.temperature,
168
168
  max_retries=self.max_retries,
169
169
  capability_overrides=dict(self.capability_overrides),
170
+ request_extra=dict(self.extra.get("request_extra") or {}),
170
171
  )
171
172
 
172
173
  def to_anthropic(self) -> AnthropicChatConfig:
@@ -0,0 +1,201 @@
1
+ """Category-level command policy for ``bash`` / ``background_run`` (rule layer).
2
+
3
+ `_dangerous_command_reason` in default_tools is the *absolute* deny-list (sudo, mkfs, rm -rf /…).
4
+ This module is the layer above it: it classifies a shell command into coarse **categories** —
5
+ installing packages, downloading files, piping a download into an interpreter, starting daemons —
6
+ and the host decides per agent which categories are blocked (``RuntimeEnv.blocked_command_categories``).
7
+
8
+ Why a category layer and not one big regex: a sandbox that can reach npm/pypi through the egress
9
+ proxy *will* have a model install playwright + chromium the first time a skill points it at a
10
+ tool it doesn't have (that is exactly what happened, and it cost 20 minutes and a few hundred MB).
11
+ The fix is not to blacklist "playwright" — it is to make "install software" a capability the
12
+ operator grants, with a refusal message that tells the model what to do instead.
13
+
14
+ Lexical only, no semantics: we split on shell operators (shlex with punctuation_chars) and look at
15
+ each simple command's program name + subcommand. False negatives are possible (``python -c
16
+ "import subprocess; …"``); that is what the host's optional LLM review layer is for. False
17
+ positives are cheap: the model gets a tool result telling it the category and the exit.
18
+
19
+ Library default: nothing blocked except ``pipe_to_shell`` (there is no legitimate use for
20
+ ``curl … | sh`` inside an agent sandbox). Hosts opt in to more.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import shlex
26
+ from pathlib import Path
27
+
28
+ CATEGORIES: tuple[str, ...] = ("package_install", "download", "pipe_to_shell", "daemon")
29
+ ALWAYS_BLOCKED: frozenset[str] = frozenset({"pipe_to_shell"})
30
+
31
+ _OPERATORS = {"|", "||", "&&", ";", "&", "(", ")", ";;", "|&"}
32
+ _INTERPRETERS = {"sh", "bash", "zsh", "dash", "ksh", "python", "python3", "node", "perl", "ruby", "php"}
33
+ _WRAPPERS = {"env", "nice", "time", "command", "exec", "builtin", "nohup", "setsid", "stdbuf", "timeout"}
34
+
35
+ _NODE_PM = {"npm", "pnpm", "yarn", "bun"}
36
+ _NODE_PM_INSTALL = {"i", "install", "add", "ci", "update", "upgrade", "up", "dlx", "exec"}
37
+ _PY_PM = {"pip", "pip3", "pipx", "poetry", "uv", "conda", "mamba", "pipenv"}
38
+ _PY_PM_INSTALL = {"install", "add", "sync", "download"}
39
+ _SYS_PM = {"apt", "apt-get", "apk", "yum", "dnf", "brew", "pacman", "zypper", "snap"}
40
+ _SYS_PM_INSTALL = {"install", "add", "reinstall", "upgrade", "dist-upgrade"}
41
+ _LANG_PM = {"cargo": {"install"}, "gem": {"install"}, "go": {"install", "get"}, "cpan": {"install"}}
42
+ _DAEMON_PROGS = {"systemctl", "service", "daemonize", "pm2", "forever", "supervisord", "supervisorctl",
43
+ "screen", "tmux", "start-stop-daemon"}
44
+ _SERVER_PROGS = {"node", "python", "python3", "npm", "npx", "pnpm", "yarn", "bun", "deno", "serve",
45
+ "http-server", "uvicorn", "gunicorn", "flask", "ruby", "php", "caddy", "nginx"}
46
+
47
+ _PIPE_TO_SHELL_RE = re.compile(
48
+ r"(?:\bcurl\b|\bwget\b)[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|python3?|node|perl|ruby)\b"
49
+ r"|<\(\s*(?:curl|wget)\b"
50
+ r"|\b(?:sh|bash|zsh|python3?|node)\s+-c\s+[\"']?\$\(\s*(?:curl|wget)\b",
51
+ re.I,
52
+ )
53
+
54
+
55
+ def _segments(command: str) -> list[list[str]]:
56
+ """Split a shell command into simple commands (token lists) on operators."""
57
+ try:
58
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
59
+ lexer.whitespace_split = True
60
+ lexer.commenters = ""
61
+ tokens = list(lexer)
62
+ except ValueError:
63
+ tokens = command.split()
64
+ segs: list[list[str]] = []
65
+ cur: list[str] = []
66
+ for tok in tokens:
67
+ if tok in _OPERATORS or tok == "\n":
68
+ if cur:
69
+ segs.append(cur)
70
+ cur = []
71
+ if tok == "&":
72
+ segs.append(["&"]) # marker: previous segment was backgrounded
73
+ else:
74
+ cur.append(tok)
75
+ if cur:
76
+ segs.append(cur)
77
+ return segs
78
+
79
+
80
+ def _program(seg: list[str]) -> tuple[str, list[str]]:
81
+ """Program basename + remaining args, skipping ``FOO=bar`` assignments and wrappers."""
82
+ i = 0
83
+ while i < len(seg):
84
+ tok = seg[i]
85
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok):
86
+ i += 1
87
+ continue
88
+ name = Path(tok).name
89
+ if name in _WRAPPERS:
90
+ i += 1
91
+ # `timeout 30 cmd`, `nice -n 5 cmd`: skip their own option/value tokens
92
+ while i < len(seg) and (seg[i].startswith("-") or re.fullmatch(r"\d+[smhd]?", seg[i])):
93
+ i += 1
94
+ continue
95
+ return name, seg[i + 1:]
96
+ return "", []
97
+
98
+
99
+ def _subcommand(args: list[str]) -> str:
100
+ for a in args:
101
+ if not a.startswith("-"):
102
+ return a
103
+ return ""
104
+
105
+
106
+ def classify_command(command: str) -> set[str]:
107
+ """Return the set of policy categories a command touches (empty = plain)."""
108
+ cats: set[str] = set()
109
+ if not command or not command.strip():
110
+ return cats
111
+ if _PIPE_TO_SHELL_RE.search(command):
112
+ cats.add("pipe_to_shell")
113
+ segs = _segments(command)
114
+ for idx, seg in enumerate(segs):
115
+ if seg == ["&"]:
116
+ continue
117
+ prog, args = _program(seg)
118
+ if not prog:
119
+ continue
120
+ sub = _subcommand(args)
121
+ backgrounded = idx + 1 < len(segs) and segs[idx + 1] == ["&"]
122
+
123
+ # ── package_install ──
124
+ if prog in _NODE_PM and (sub in _NODE_PM_INSTALL or (prog == "yarn" and not sub)):
125
+ cats.add("package_install")
126
+ elif prog == "npx" or prog == "corepack":
127
+ cats.add("package_install")
128
+ elif prog in _PY_PM:
129
+ subs = {a for a in args if not a.startswith("-")}
130
+ if subs & _PY_PM_INSTALL or (prog == "uv" and "pip" in subs and "install" in subs):
131
+ cats.add("package_install")
132
+ elif prog.startswith("python") and "-m" in args:
133
+ m = args.index("-m")
134
+ if m + 1 < len(args) and args[m + 1] in {"pip", "ensurepip"} and "install" in args:
135
+ cats.add("package_install")
136
+ elif prog in _SYS_PM and sub in _SYS_PM_INSTALL:
137
+ cats.add("package_install")
138
+ elif prog in _LANG_PM and sub in _LANG_PM[prog]:
139
+ cats.add("package_install")
140
+ elif prog == "playwright" and sub == "install":
141
+ cats.add("package_install")
142
+
143
+ # ── download ──
144
+ if prog == "curl":
145
+ if any(a in {"-o", "-O", "--output", "--remote-name", "-J", "--remote-header-name"}
146
+ or a.startswith(("-o", "--output=")) or re.fullmatch(r"-[a-zA-Z]*[oO][a-zA-Z]*", a)
147
+ for a in args):
148
+ cats.add("download")
149
+ elif prog == "wget":
150
+ to_stdout = any(a in {"-O-", "-qO-", "-O", "--output-document=-"} and (a.endswith("-") or
151
+ (a == "-O" and "-" in args[args.index(a) + 1:args.index(a) + 2]))
152
+ for a in args)
153
+ if not to_stdout:
154
+ cats.add("download")
155
+ elif prog in {"aria2c", "axel"}:
156
+ cats.add("download")
157
+ elif prog == "git" and sub in {"clone", "fetch", "pull"}:
158
+ cats.add("download")
159
+
160
+ # ── daemon ──
161
+ if prog in _DAEMON_PROGS:
162
+ cats.add("daemon")
163
+ elif backgrounded and prog in _SERVER_PROGS:
164
+ cats.add("daemon")
165
+ # wrappers that only exist to daemonize
166
+ if seg and Path(seg[0]).name in {"nohup", "setsid"}:
167
+ cats.add("daemon")
168
+ return cats
169
+
170
+
171
+ _EXITS = {
172
+ "package_install": (
173
+ "installing packages (npm/pip/apt/cargo…) is disabled for this agent. Use what is preinstalled "
174
+ "in the sandbox; if something is genuinely missing, tell the user so the platform can add it. "
175
+ "For screenshots of HTML prototypes use the render_html tool, not a browser install."
176
+ ),
177
+ "download": (
178
+ "downloading files with curl/wget/git clone is disabled for this agent. Use the platform's "
179
+ "fetch_file / web_read tools for content you need, or ask the user to provide the file."
180
+ ),
181
+ "pipe_to_shell": (
182
+ "piping a download straight into a shell/interpreter (curl … | sh) is never allowed."
183
+ ),
184
+ "daemon": (
185
+ "starting long-running/background daemons is disabled for this agent. Run the command in the "
186
+ "foreground with a timeout, or use the background_run tool for a bounded job."
187
+ ),
188
+ }
189
+
190
+
191
+ def command_policy_reason(command: str, blocked: frozenset[str] | set[str] | None) -> str | None:
192
+ """Return a refusal message if ``command`` touches a blocked category, else None."""
193
+ effective = set(ALWAYS_BLOCKED) | set(blocked or ())
194
+ hit = classify_command(command) & effective
195
+ if not hit:
196
+ return None
197
+ cat = next(c for c in CATEGORIES if c in hit) # stable, most-severe-first order
198
+ return f"Error: Command blocked by sandbox policy ({cat}): {_EXITS[cat]}"
199
+
200
+
201
+ __all__ = ["ALWAYS_BLOCKED", "CATEGORIES", "classify_command", "command_policy_reason"]
@@ -23,6 +23,7 @@ from power_loop.runtime.exec_backend import DEFAULT_SHELL_BACKEND, ShellBackend
23
23
  from power_loop.runtime.human_input import request_user_input
24
24
  from power_loop.runtime.runtime_state import get_tool_runtime_context
25
25
  from power_loop.runtime.skills import get_default_loader
26
+ from power_loop.tools.command_policy import command_policy_reason
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -531,6 +532,9 @@ class BashSession:
531
532
  reason = _dangerous_command_reason(command)
532
533
  if reason:
533
534
  return f"Error: Dangerous command blocked ({reason}). Run it manually if you really intend it."
535
+ policy_err = command_policy_reason(command, get_runtime_env().blocked_command_categories)
536
+ if policy_err:
537
+ return policy_err
534
538
 
535
539
  with self._lock:
536
540
  if self._proc is None or self._proc.poll() is not None:
@@ -1220,6 +1224,9 @@ class BackgroundManager:
1220
1224
  reason = _dangerous_command_reason(command)
1221
1225
  if reason:
1222
1226
  return f"Error: Dangerous command blocked ({reason}). Run it manually if you really intend it."
1227
+ policy_err = command_policy_reason(command, get_runtime_env().blocked_command_categories)
1228
+ if policy_err:
1229
+ return policy_err
1223
1230
 
1224
1231
  scope_err = _validate_bash_command_scope(command)
1225
1232
  if scope_err:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 5.2.2
3
+ Version: 5.3.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -84,6 +84,7 @@ power_loop/runtime/store/backends/postgres.py
84
84
  power_loop/runtime/store/backends/sqlite.py
85
85
  power_loop/tools/__init__.py
86
86
  power_loop/tools/blackboard.py
87
+ power_loop/tools/command_policy.py
87
88
  power_loop/tools/default_manifest.py
88
89
  power_loop/tools/default_tools.py
89
90
  power_loop/tools/registry.py
File without changes
File without changes
File without changes
File without changes