ws-aitoolkit 0.1.1__tar.gz → 0.1.2__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 (94) hide show
  1. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/PKG-INFO +10 -1
  2. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/README.md +9 -0
  3. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/pyproject.toml +1 -1
  4. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/pyproject.toml.orig +1 -1
  5. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/_build.py +2 -2
  6. ws_aitoolkit-0.1.2/src/ws/adapters/__init__.py +99 -0
  7. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/adapters/claude_code.py +164 -22
  8. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/adapters/opencode.py +34 -6
  9. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/adapters/opencode_plugin.py +3 -1
  10. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/approvals.py +7 -2
  11. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/broker.py +7 -2
  12. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/cli.py +19 -3
  13. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/doctor.py +13 -2
  14. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/eval.py +44 -4
  15. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/evalstats.py +37 -0
  16. ws_aitoolkit-0.1.2/src/ws/extcmd.py +293 -0
  17. ws_aitoolkit-0.1.2/src/ws/gate.py +390 -0
  18. ws_aitoolkit-0.1.2/src/ws/guards/__init__.py +204 -0
  19. ws_aitoolkit-0.1.2/src/ws/guards/_paths.py +52 -0
  20. ws_aitoolkit-0.1.2/src/ws/guards/bulk_staging.py +218 -0
  21. ws_aitoolkit-0.1.2/src/ws/guards/claims.py +228 -0
  22. ws_aitoolkit-0.1.2/src/ws/guards/record_calls.py +100 -0
  23. ws_aitoolkit-0.1.2/src/ws/guards/session_context.py +86 -0
  24. ws_aitoolkit-0.1.2/src/ws/guards/skill_gate.py +98 -0
  25. ws_aitoolkit-0.1.2/src/ws/guards/spawn_cap.py +93 -0
  26. ws_aitoolkit-0.1.2/src/ws/guards/syntax_check.py +129 -0
  27. ws_aitoolkit-0.1.2/src/ws/guards/test_weakening.py +238 -0
  28. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/install.py +130 -4
  29. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/modules.py +1 -1
  30. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/policy.py +337 -26
  31. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/commands.py +52 -5
  32. ws_aitoolkit-0.1.2/src/ws/roles/ntfy.py +500 -0
  33. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/secrets.py +103 -0
  34. ws_aitoolkit-0.1.2/src/ws/selfcmd.py +55 -0
  35. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/session.py +8 -4
  36. ws_aitoolkit-0.1.2/src/ws/skills/__init__.py +460 -0
  37. ws_aitoolkit-0.1.2/src/ws/skills/resume.py +55 -0
  38. ws_aitoolkit-0.1.2/src/ws/skills/ship.py +56 -0
  39. ws_aitoolkit-0.1.2/src/ws/userconfig.py +897 -0
  40. ws_aitoolkit-0.1.1/src/ws/adapters/__init__.py +0 -68
  41. ws_aitoolkit-0.1.1/src/ws/gate.py +0 -210
  42. ws_aitoolkit-0.1.1/src/ws/roles/ntfy.py +0 -287
  43. ws_aitoolkit-0.1.1/src/ws/selfcmd.py +0 -19
  44. ws_aitoolkit-0.1.1/src/ws/skills/__init__.py +0 -191
  45. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/LICENSE +0 -0
  46. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/NOTICE +0 -0
  47. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/__init__.py +0 -0
  48. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/__main__.py +0 -0
  49. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/canary.py +0 -0
  50. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/config_cmd.py +0 -0
  51. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/devices.py +0 -0
  52. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/frontmatter.py +0 -0
  53. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/globals.py +0 -0
  54. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/jobs.py +0 -0
  55. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/jsonl.py +0 -0
  56. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/mcp_server.py +0 -0
  57. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/memory.py +0 -0
  58. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/notes.py +0 -0
  59. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/notesdetect.py +0 -0
  60. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/osinfo.py +0 -0
  61. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/output.py +0 -0
  62. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/profile.py +0 -0
  63. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/rating.py +0 -0
  64. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/__init__.py +0 -0
  65. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/_schedule.py +0 -0
  66. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/anthropic.py +0 -0
  67. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/launchd.py +0 -0
  68. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/markdown_git.py +0 -0
  69. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/notify_none.py +0 -0
  70. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/opencode.py +0 -0
  71. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/restic.py +0 -0
  72. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/secrets_env.py +0 -0
  73. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/secrets_ws.py +0 -0
  74. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/sops_age.py +0 -0
  75. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/systemd.py +0 -0
  76. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/run.py +0 -0
  77. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/runrecord.py +0 -0
  78. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/runschema.py +0 -0
  79. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/sandbox.py +0 -0
  80. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/__init__.py +0 -0
  81. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/brand.py +0 -0
  82. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/flow.py +0 -0
  83. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/inline.py +0 -0
  84. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/web.py +0 -0
  85. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/align.py +0 -0
  86. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/checkpoint.py +0 -0
  87. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/dataviz.py +0 -0
  88. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/taste_gate.py +0 -0
  89. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/status.py +0 -0
  90. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/testing/__init__.py +0 -0
  91. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/testing/mockprovider.py +0 -0
  92. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/testing/scenarios.py +0 -0
  93. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/usage.py +0 -0
  94. {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ws-aitoolkit
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Your memory, skills and rules, in any AI.
5
5
  Author: Michael Fry
6
6
  License-Expression: Apache-2.0
@@ -61,6 +61,15 @@ questions, or `ws setup` to go step by step. `ws doctor --json` gives the same r
61
61
 
62
62
  To follow a whole first session, see the [first-run tutorial](docs/tutorials/first-run.md).
63
63
 
64
+ ## Skills
65
+
66
+ `ws install` puts six skills in your runner's skills folder: `align`, `checkpoint`, `taste-gate`, `dataviz`, `resume`
67
+ and `ship`. `ws skills` lists them per runner. A skill you already have under one of those names stays as it is.
68
+
69
+ To use ws's version of a name you already have, run `ws skills adopt NAME`. Yours moves to
70
+ `$WS_HOME/backups/skills/<runner>/` and ws installs its own. `ws skills restore NAME` takes ws's copy out and moves yours
71
+ back.
72
+
64
73
  ## Verify
65
74
 
66
75
  Check which build you have:
@@ -44,6 +44,15 @@ questions, or `ws setup` to go step by step. `ws doctor --json` gives the same r
44
44
 
45
45
  To follow a whole first session, see the [first-run tutorial](docs/tutorials/first-run.md).
46
46
 
47
+ ## Skills
48
+
49
+ `ws install` puts six skills in your runner's skills folder: `align`, `checkpoint`, `taste-gate`, `dataviz`, `resume`
50
+ and `ship`. `ws skills` lists them per runner. A skill you already have under one of those names stays as it is.
51
+
52
+ To use ws's version of a name you already have, run `ws skills adopt NAME`. Yours moves to
53
+ `$WS_HOME/backups/skills/<runner>/` and ws installs its own. `ws skills restore NAME` takes ws's copy out and moves yours
54
+ back.
55
+
47
56
  ## Verify
48
57
 
49
58
  Check which build you have:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ws-aitoolkit"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Your memory, skills and rules, in any AI."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ws-aitoolkit"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Your memory, skills and rules, in any AI."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  """Written by the release workflow before uv build; absent in a checkout."""
2
2
 
3
- git_sha = "6bf31217fc9ae246b5a0dba2f91eb98e5f0e696a"
3
+ git_sha = "92389d65eb2553fe9d570d9fd6f37cb386b08c01"
4
4
  dirty = False
5
- build_date = "2026-10-31T21:45:24Z"
5
+ build_date = "2026-11-01T07:40:02Z"
6
6
  source = "release"
@@ -0,0 +1,99 @@
1
+ """Runner adapters: one module per runner. To add a runner, add a module here; nothing else changes.
2
+
3
+ A module must define:
4
+
5
+ NAME: str the --runner name, e.g. "claude-code"
6
+ def generate(policy, ws_cmd, out, policy_file, *, protect=()) -> dict
7
+ file name (relative to the out dir) -> text; pure, no I/O.
8
+ `ws_cmd` is the argv list that runs ws, e.g. ["ws"]; `out` is the
9
+ folder the files go in and `policy_file` the policy they came from:
10
+ both are ws's own files, so the runner must not let a session edit
11
+ them (see check_argv for the hook's side of that). `protect` names
12
+ more paths a session may not write (deny rules, the hook's
13
+ --protect arguments, the sandbox's denyWrite).
14
+ def parse(payload) -> Call | list[Call] | None
15
+ a hook payload from that runner, as neutral Call(s); None if the call
16
+ is not one the policy governs; ValueError or TypeError if it cannot be
17
+ read (`ws gate check` then denies: fail closed).
18
+ def launch_hint(out) -> str one line: how to start the runner with the generated config.
19
+
20
+ and, optionally:
21
+
22
+ UNSUPPORTED_GUARDS: tuple[str, ...] guard names (ws.guards) this runner's generated config does not run.
23
+ EVENT_NAMES: dict[str, str] the runner's hook event name -> a ws.guards event ("pre", "post",
24
+ "start", "prompt", "stop", "subagent-stop"), for `ws gate event`.
25
+
26
+ Modules are found by scanning this package, so the registry is the directory.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import importlib
32
+ import pkgutil
33
+ from collections.abc import Mapping
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from types import MappingProxyType, ModuleType
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Call:
41
+ """A tool call in runner-neutral terms. kind: "command" (shell), "read" (one file read), "search" (a folder
42
+ searched or listed, recursively), "write" (a file written or edited), "tool" (a neutral tool name from
43
+ ws.policy.KNOWN_TOOLS), "agent" (a subagent started), "skill" (a skill loaded) or "message" (a message sent to
44
+ another agent). value: the command text, the path, the tool name, the agent type, the skill name or the
45
+ recipient. data: what else the tool gave, read-only: a write has `old`, `new`, `content` or `edits` as the tool
46
+ supplies them; an agent has `prompt`, `description` and `subagent_type`; a skill has `name`; a message has `text`.
47
+ The policy judges command, read, search, write and tool calls; the other kinds reach guards only."""
48
+
49
+ kind: str
50
+ value: str
51
+ data: Mapping = field(default_factory=lambda: MappingProxyType({}), hash=False)
52
+
53
+ @classmethod
54
+ def of(cls, kind: str, value: str, **data) -> Call:
55
+ """A Call with its `data` set (read-only)."""
56
+ return cls(kind, value, MappingProxyType(data))
57
+
58
+
59
+ def all_adapters() -> dict[str, ModuleType]:
60
+ found = {}
61
+ for info in pkgutil.iter_modules(__path__):
62
+ mod = importlib.import_module(f"{__name__}.{info.name}")
63
+ if hasattr(mod, "NAME") and hasattr(mod, "generate"):
64
+ found[mod.NAME] = mod
65
+ return dict(sorted(found.items()))
66
+
67
+
68
+ def get(name: str) -> ModuleType:
69
+ adapters = all_adapters()
70
+ if name not in adapters:
71
+ raise KeyError(f"unknown runner {name!r} (known: {', '.join(adapters)})")
72
+ return adapters[name]
73
+
74
+
75
+ def protected_paths(out: Path, policy_file: Path, extra: tuple[str, ...] = ()) -> list[str]:
76
+ """Everything a session must not write: the generated folder, the policy, ws's other control files next to
77
+ it (profile, secrets, jobs, release, runs, logs), and any `extra` path the caller names (a user settings file).
78
+ Each adapter denies edits to all of them natively."""
79
+ from ws.policy import control_paths
80
+
81
+ paths = [str(out), str(policy_file), *control_paths(policy_file.parent), *extra]
82
+ return list(dict.fromkeys(paths))
83
+
84
+
85
+ def check_argv(
86
+ ws_cmd: list[str], runner: str, policy_file: Path, out: Path, protect: tuple[str, ...] = ()
87
+ ) -> list[str]:
88
+ """The command a generated hook or shim runs. The policy and the protected folder are absolute paths, so the hook
89
+ reads the same policy wherever the runner starts and cannot be pointed at another one by the session's cwd. Each
90
+ `protect` path is one more `--protect`."""
91
+ argv = [*ws_cmd, "gate", "check", "--runner", runner, "--policy", str(policy_file), "--protect", str(out)]
92
+ for p in protect:
93
+ argv += ["--protect", str(p)]
94
+ return argv
95
+
96
+
97
+ def event_argv(ws_cmd: list[str], runner: str, policy_file: Path) -> list[str]:
98
+ """The command a generated hook runs for the events that are not a tool call's check: `ws gate event`."""
99
+ return [*ws_cmd, "gate", "event", "--runner", runner, "--policy", str(policy_file)]
@@ -41,7 +41,29 @@ sandbox off or open it up, because an interactive session cannot be started with
41
41
  The sandbox covers shell commands; Claude's own Read, Edit and Write tools are covered by the permission rules above and
42
42
  the hook, not by it. `env` carries WS_GATED_SESSION so `ws approve` knows it is being run from a gated session.
43
43
 
44
- Pass the file with `claude --settings OUT/settings.json`. Nothing here touches ~/.claude.
44
+ Guards (ws.guards) add hooks, only for the events and tools the guards that are on need: PreToolUse matches more tools
45
+ (Agent, Task, Skill, SendMessage) when a guard reads them; PostToolUse, SubagentStop and the prompt event run
46
+ `ws gate event`; SessionStart and Stop run it beside what they already run.
47
+
48
+ Which hooks can block: PreToolUse (`ws gate check`) denies a call, and its command ends `|| exit 2` so a missing or
49
+ crashing ws blocks too. PostToolUse (`ws gate event`) exits 2 on purpose when a check finds a problem, and Claude Code
50
+ shows that stderr to the model; it carries no `|| exit`. Every other generated hook command (SessionStart,
51
+ UserPromptSubmit, Stop, SubagentStop) ends `|| true`, because an exit 2 there would block the person's prompt or keep the
52
+ session going: those hooks add context and findings, and the session carries on whatever they return.
53
+
54
+ Notification and Stop also run `ws notify alert --runner claude-code || true`, the phone alert.
55
+
56
+ `env` also carries WS_SANDBOXED=1, which tells the hook this session has the sandbox block above. `preflight` runs for
57
+ every session except one whose settings set WS_SANDBOXED=0, the marker of a settings file written without the sandbox
58
+ block; settings written before the marker existed carry the block and no marker, so `preflight` runs for them.
59
+
60
+ Pass the file with `claude --settings OUT/settings.json`. Nothing in this module writes ~/.claude: `ws install` merges
61
+ the hooks, deny rules and env of this file into the user's own `settings.json` when the profile says
62
+ `gate = "every-session"` (ws.userconfig). That copy is built with `user_scope=True`, which swaps the project-lock Edit
63
+ rules above for `USER_LOCKS`. In every session, not only inside the wall, a rule such as `Edit(**/.claude/**)` would
64
+ also block a session working in a `.claude/worktrees/` checkout, so the shared file locks only the files Claude Code
65
+ or the version-control tool runs as code. It also leaves out WS_GATED_SESSION, so `ws approve` still works from the
66
+ person's own Claude shell (the hook denies it to the model whatever the env says).
45
67
  """
46
68
 
47
69
  from __future__ import annotations
@@ -53,9 +75,9 @@ import re
53
75
  import shlex
54
76
  from pathlib import Path
55
77
 
56
- from ws import jobs
57
- from ws.adapters import Call, check_argv, protected_paths
58
- from ws.approvals import ENV_MARKER, ApprovalError
78
+ from ws import guards, jobs
79
+ from ws.adapters import Call, check_argv, event_argv, protected_paths
80
+ from ws.approvals import ENV_MARKER, SANDBOX_MARKER, ApprovalError
59
81
  from ws.approvals import _read_regular as read_regular
60
82
  from ws.policy import Policy, native_patterns, sandbox_read_paths, sandbox_write_paths
61
83
 
@@ -64,6 +86,37 @@ FILE = "settings.json"
64
86
  MATCHER = "Bash|Read|Grep|Glob|WebFetch|WebSearch|Edit|Write|MultiEdit|NotebookEdit"
65
87
  _TOOLS = {"web_fetch": "WebFetch", "web_search": "WebSearch"}
66
88
  _NEUTRAL = {v: k for k, v in _TOOLS.items()}
89
+ # The project files that run as code, at any depth. These are the Edit locks in the user's own settings.json (every
90
+ # session); the wide set from ws.sandbox.claude_denied() stays in the file `ws gate launch` uses, inside the wall.
91
+ USER_LOCKS = (
92
+ "Edit(**/.claude/settings.json)",
93
+ "Edit(**/.claude/settings.local.json)",
94
+ "Edit(**/.mcp.json)",
95
+ "Edit(**/.git/hooks/**)",
96
+ "Edit(**/.git/config)",
97
+ )
98
+ # Claude Code's hook event names, as ws.guards names them.
99
+ EVENT_NAMES = {
100
+ "PreToolUse": "pre",
101
+ "PostToolUse": "post",
102
+ "SessionStart": "start",
103
+ "UserPromptSubmit": "prompt",
104
+ "Stop": "stop",
105
+ "SubagentStop": "subagent-stop",
106
+ }
107
+ # The tools a Call kind comes from, in the order a matcher lists them.
108
+ _KIND_TOOLS = {
109
+ "command": ("Bash",),
110
+ "read": ("Read",),
111
+ "search": ("Grep", "Glob"),
112
+ "tool": ("WebFetch", "WebSearch"),
113
+ "write": ("Edit", "Write", "MultiEdit", "NotebookEdit"),
114
+ "agent": ("Agent", "Task"),
115
+ "skill": ("Skill",),
116
+ "message": ("SendMessage",),
117
+ }
118
+ _EXTRA_PRE_KINDS = ("agent", "skill", "message") # the PreToolUse matcher covers the other kinds already
119
+ HOOK_TIMEOUT = 20 # seconds: the syntax checkers get 10
67
120
 
68
121
  # Where this runner keeps what ws installs globally, and where a repo may shadow it (read by ws.globals).
69
122
  # kinds: sub-folder -> how a name is taken: "dir" the folder name, "name" the file name, "stem" the name without extension.
@@ -87,15 +140,51 @@ def session_hooks(ws_cmd: list[str]) -> dict:
87
140
  return {"SessionStart": hook("start"), "UserPromptSubmit": hook("nudge")}
88
141
 
89
142
 
143
+ def alert_hook(ws_cmd: list[str]) -> dict:
144
+ """The phone alert: `ws notify alert` reads the hook payload and always exits 0; `|| true` keeps it advice."""
145
+ cmd = f"{shlex.join(ws_cmd)} notify alert --runner {NAME} || true"
146
+ return {"type": "command", "command": cmd, "timeout": 5}
147
+
148
+
149
+ def _matcher(kinds: set[str], base: tuple[str, ...] = ()) -> str:
150
+ tools = [*base]
151
+ for kind, names in _KIND_TOOLS.items():
152
+ if kind in kinds:
153
+ tools += [t for t in names if t not in tools]
154
+ return "|".join(tools)
155
+
156
+
157
+ def _event_hook(ws_cmd: list[str], policy_file: Path, *, may_block: bool = False) -> dict:
158
+ """A `ws gate event` hook. It ends `|| true` unless it may block (PostToolUse, whose exit 2 reaches the model)."""
159
+ cmd = shlex.join(event_argv(ws_cmd, NAME, policy_file)) + ("" if may_block else " || true")
160
+ return {"type": "command", "command": cmd, "timeout": HOOK_TIMEOUT}
161
+
162
+
163
+ def guard_hooks(policy: Policy, ws_cmd: list[str], policy_file: Path) -> dict:
164
+ """The hooks beyond the PreToolUse check and the session hooks: `ws gate event` for each event a guard that is on
165
+ runs at (PostToolUse, Stop, SubagentStop). Returns {event name: [matcher groups]} to merge with the session hooks."""
166
+ on = policy.guards.on
167
+ hooks: dict[str, list[dict]] = {}
168
+ post = guards.kinds_for(on, "post")
169
+ if post:
170
+ hooks["PostToolUse"] = [
171
+ {"matcher": _matcher(post), "hooks": [_event_hook(ws_cmd, policy_file, may_block=True)]}
172
+ ]
173
+ for name, ev in (("Stop", "stop"), ("SubagentStop", "subagent-stop")):
174
+ if guards.wanted(on, ev):
175
+ hooks[name] = [{"hooks": [_event_hook(ws_cmd, policy_file)]}]
176
+ return hooks
177
+
178
+
90
179
  def _path(path: str) -> str:
91
180
  """Claude's path syntax: `~/x` is home, `//x` is an absolute path, anything else is cwd-relative gitignore."""
92
181
  return f"/{path}" if path.startswith("/") else path
93
182
 
94
183
 
95
- def sandbox_block(policy: Policy, out: Path, policy_file: Path) -> dict:
184
+ def sandbox_block(policy: Policy, out: Path, policy_file: Path, protect: tuple[str, ...] = ()) -> dict:
96
185
  """The `sandbox` settings: see the module docstring for what each key is for."""
97
186
  home = str(Path(policy_file).parent)
98
- fs: dict = {"denyWrite": list(dict.fromkeys([home, *protected_paths(out, policy_file)]))}
187
+ fs: dict = {"denyWrite": list(dict.fromkeys([home, *protected_paths(out, policy_file, protect)]))}
99
188
  if policy.gate.deny_read:
100
189
  fs["denyRead"] = sandbox_read_paths(policy)
101
190
  if policy.gate.allow_write: # Claude Code already allows its project folder and tmp; these are the person's extras
@@ -118,7 +207,11 @@ def preflight(payload: dict) -> str | None:
118
207
  folder is CLAUDE_PROJECT_DIR (set for hooks; Claude reads project settings from there, wherever the session has since
119
208
  changed to), else the payload's cwd. A file that does not parse is ignored, as Claude Code ignores it. A settings
120
209
  file that is not a plain file (a link, a FIFO), is too big or cannot be read is a deny: it is read without following a
121
- link and without blocking, because a hook that hangs fails open (only exit 2 blocks)."""
210
+ link and without blocking, because a hook that hangs fails open (only exit 2 blocks). It is skipped only when the
211
+ settings `env` says WS_SANDBOXED=0 (the marker of settings written without the sandbox block); settings written
212
+ before the marker existed have the block and no marker, so it runs for them."""
213
+ if os.environ.get(SANDBOX_MARKER) == "0":
214
+ return None
122
215
  project = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd")
123
216
  if not isinstance(project, str) or not project.startswith("/"):
124
217
  return None
@@ -176,27 +269,47 @@ def for_project(settings: str, project: str) -> str:
176
269
  return json.dumps(conf, indent=2) + "\n"
177
270
 
178
271
 
179
- def generate(policy: Policy, ws_cmd: list[str], out: Path, policy_file: Path) -> dict[str, str]:
272
+ def generate(
273
+ policy: Policy,
274
+ ws_cmd: list[str],
275
+ out: Path,
276
+ policy_file: Path,
277
+ *,
278
+ protect: tuple[str, ...] = (),
279
+ user_scope: bool = False,
280
+ ) -> dict[str, str]:
281
+ protect = tuple(protect)
180
282
  deny = [f"Bash({p})" for p in native_patterns(policy)]
181
283
  deny += [f"Read({_path(p)})" for p in policy.gate.deny_read]
182
- for p in protected_paths(out, policy_file): # a file or a folder: cover both; Edit rules cover Write too
284
+ for p in protected_paths(out, policy_file, protect): # a file or a folder: cover both; Edit rules cover Write too
183
285
  deny += [f"Edit({_path(p)})", f"Edit({_path(p)}/**)"]
184
- from ws import sandbox
185
-
186
- for rel in sandbox.claude_denied(): # the project's own config and git hooks: see ws.sandbox PROJECT_LOCKED
187
- deny += [
188
- f"Edit(**/{rel})",
189
- f"Edit(**/{rel}/**)",
190
- ] # at any depth: a runner started in a sub-folder reads that one
286
+ if user_scope:
287
+ deny += USER_LOCKS
288
+ else:
289
+ from ws import sandbox
290
+
291
+ for rel in sandbox.claude_denied(): # the project's own config and git hooks: see ws.sandbox PROJECT_LOCKED
292
+ deny += [
293
+ f"Edit(**/{rel})",
294
+ f"Edit(**/{rel}/**)",
295
+ ] # at any depth: a runner started in a sub-folder reads that one
191
296
  deny += [_TOOLS[t] for t in policy.gate.deny_tools]
192
- hook = shlex.join(check_argv(ws_cmd, NAME, policy_file, out)) + " || exit 2"
297
+ hook = shlex.join(check_argv(ws_cmd, NAME, policy_file, out, protect)) + " || exit 2"
298
+ matcher = _matcher(guards.kinds_for(policy.guards.on, "pre") & set(_EXTRA_PRE_KINDS), tuple(MATCHER.split("|")))
193
299
  settings = {
194
300
  "permissions": {"deny": list(dict.fromkeys(deny))},
195
- "sandbox": sandbox_block(policy, out, policy_file),
196
- "env": {ENV_MARKER: "1"},
197
- "hooks": {"PreToolUse": [{"matcher": MATCHER, "hooks": [{"type": "command", "command": hook}]}]},
301
+ "sandbox": sandbox_block(policy, out, policy_file, protect),
302
+ "env": {ENV_MARKER: "1", SANDBOX_MARKER: "1"},
303
+ "hooks": {"PreToolUse": [{"matcher": matcher, "hooks": [{"type": "command", "command": hook}]}]},
198
304
  }
199
305
  settings["hooks"].update(session_hooks(ws_cmd))
306
+ for name, ev in (("SessionStart", "start"), ("UserPromptSubmit", "prompt")):
307
+ if guards.wanted(policy.guards.on, ev):
308
+ settings["hooks"][name][0]["hooks"].append(_event_hook(ws_cmd, policy_file))
309
+ for name, groups in guard_hooks(policy, ws_cmd, policy_file).items():
310
+ settings["hooks"][name] = groups
311
+ settings["hooks"]["Notification"] = [{"hooks": [alert_hook(ws_cmd)]}]
312
+ settings["hooks"].setdefault("Stop", [{"hooks": []}])[0]["hooks"].append(alert_hook(ws_cmd))
200
313
  return {FILE: json.dumps(settings, indent=2) + "\n"}
201
314
 
202
315
 
@@ -211,9 +324,18 @@ def parse(payload: dict) -> Call | list[Call] | None:
211
324
  if name == "Read":
212
325
  return Call("read", _text(inp, "file_path"))
213
326
  if name in ("Edit", "Write", "MultiEdit"):
214
- return Call("write", _text(inp, "file_path"))
327
+ return Call.of("write", _text(inp, "file_path"), tool=name, **_write_data(name, inp))
215
328
  if name == "NotebookEdit":
216
- return Call("write", _text(inp, "notebook_path"))
329
+ return Call.of("write", _text(inp, "notebook_path"), tool=name, **_write_data(name, inp))
330
+ if name in ("Agent", "Task"):
331
+ found = {k: inp[k] for k in ("prompt", "description", "subagent_type") if isinstance(inp.get(k), str)}
332
+ return Call.of("agent", found.get("subagent_type", ""), tool=name, **found)
333
+ if name == "Skill":
334
+ skill = inp.get("skill") if isinstance(inp.get("skill"), str) else _optional(inp, "name") or ""
335
+ return Call.of("skill", skill, tool=name, name=skill, args=_optional(inp, "args") or "")
336
+ if name == "SendMessage":
337
+ text = next((inp[k] for k in ("message", "text", "content") if isinstance(inp.get(k), str)), "")
338
+ return Call.of("message", _optional(inp, "to") or "", tool=name, text=text)
217
339
  if name in ("Grep", "Glob"):
218
340
  calls = [Call("search", _optional(inp, "path") or ".")]
219
341
  pattern = inp.get("pattern")
@@ -225,6 +347,26 @@ def parse(payload: dict) -> Call | list[Call] | None:
225
347
  return None
226
348
 
227
349
 
350
+ def _write_data(tool: str, inp: dict) -> dict:
351
+ """What a write tool gives besides the path: `old` and `new` (Edit), `content` (Write), `edits` (MultiEdit, as a
352
+ tuple of {old, new}), `new` (NotebookEdit). Only text the tool really sent."""
353
+ found: dict = {}
354
+ for key, name in (("old_string", "old"), ("new_string", "new"), ("content", "content"), ("new_source", "new")):
355
+ if isinstance(inp.get(key), str):
356
+ found[name] = inp[key]
357
+ edits = inp.get("edits")
358
+ if tool == "MultiEdit" and isinstance(edits, list):
359
+ found["edits"] = tuple(
360
+ {
361
+ k: e[key] if isinstance(e.get(key), str) else None
362
+ for k, key in (("old", "old_string"), ("new", "new_string"))
363
+ }
364
+ for e in edits
365
+ if isinstance(e, dict)
366
+ ) # a recorded call (record-calls) holds a size and fingerprint where the text was: None here
367
+ return found
368
+
369
+
228
370
  def _text(d: dict, key: str) -> str:
229
371
  v = d.get(key)
230
372
  if not isinstance(v, str):
@@ -22,10 +22,11 @@ from __future__ import annotations
22
22
 
23
23
  import json
24
24
  import math
25
+ import re
25
26
  import shlex
26
27
  from pathlib import Path
27
28
 
28
- from ws import jobs
29
+ from ws import guards, jobs
29
30
  from ws.adapters import Call, check_argv, protected_paths
30
31
  from ws.adapters.opencode_plugin import SOURCE
31
32
  from ws.policy import Policy, native_patterns
@@ -37,6 +38,8 @@ _TOOLS = {"web_fetch": "webfetch", "web_search": "websearch"}
37
38
  # For ws.sandbox: the env var that names the generated folder. The whole folder is read-only in the wall (OpenCode loads
38
39
  # plugin/, tool/, agent/, command/ and opencode.jsonc from it, so a planted file there would outlive the session).
39
40
  CONFIG_DIR_ENV = "OPENCODE_CONFIG_DIR"
41
+ # The guards (ws.guards) the generated shim does not run yet: all of them. `ws doctor` names them when a policy has one on.
42
+ UNSUPPORTED_GUARDS = guards.names()
40
43
 
41
44
 
42
45
  # OpenCode writes this file into its config folder at start and will not run if it cannot (1.19.4). Written here at
@@ -111,7 +114,10 @@ def _protect_forms(path: str, is_out: bool) -> list[str]:
111
114
  return [path, f"{path}/**", f"**/{tail}", f"**/{tail}/**"]
112
115
 
113
116
 
114
- def generate(policy: Policy, ws_cmd: list[str], out: Path, policy_file: Path) -> dict[str, str]:
117
+ def generate(
118
+ policy: Policy, ws_cmd: list[str], out: Path, policy_file: Path, *, protect: tuple[str, ...] = ()
119
+ ) -> dict[str, str]:
120
+ protect = tuple(protect)
115
121
  # Last matching rule wins, so the catch-all comes first.
116
122
  bash: dict[str, str] = {"*": "allow"}
117
123
  bash.update(dict.fromkeys(native_patterns(policy), "deny"))
@@ -120,19 +126,32 @@ def generate(policy: Policy, ws_cmd: list[str], out: Path, policy_file: Path) ->
120
126
  rules = {"*": "allow", **dict.fromkeys((f for r in policy.gate.deny_read for f in _read_forms(r)), "deny")}
121
127
  permission.update(read=rules, grep=dict(rules), glob=dict(rules), list=dict(rules))
122
128
  edit = {"*": "allow"} # covers write and patch too; a path may be a file or a folder: cover both
123
- for p in protected_paths(out, policy_file):
129
+ for p in protected_paths(out, policy_file, protect):
124
130
  edit.update(dict.fromkeys(_protect_forms(p, p == str(out)), "deny"))
125
131
  permission["edit"] = edit
126
132
  for t in policy.gate.deny_tools:
127
133
  permission[_TOOLS[t]] = "deny"
128
134
  config = {"$schema": "https://opencode.ai/config.json", "permission": permission}
129
- plugin = SOURCE.replace("/*__CHECK__*/ []", json.dumps(check_argv(ws_cmd, NAME, policy_file, out)))
135
+ plugin = SOURCE.replace("/*__CHECK__*/ []", json.dumps(check_argv(ws_cmd, NAME, policy_file, out, protect)))
130
136
  plugin = plugin.replace("/*__WS__*/ []", json.dumps(ws_cmd))
131
137
  return {CONFIG: json.dumps(config, indent=2) + "\n", PLUGIN: plugin, IGNORE: IGNORE_TEXT}
132
138
 
133
139
 
134
- def parse(payload: dict) -> Call | None:
135
- """What the shim sends: {"tool": "bash", "args": {"command": ...}}."""
140
+ _PATCH_PATH = re.compile(r"^\*\*\* (?:Add File|Update File|Delete File|Move to):[ \t]*(\S.*?)[ \t\r]*$", re.MULTILINE)
141
+
142
+
143
+ def patch_paths(patch: str) -> list[str]:
144
+ """The files a patch (OpenCode's `*** Begin Patch` text) adds, updates, deletes or moves to, in order. A patch
145
+ that names none cannot be judged, so it raises: the call is denied."""
146
+ paths = list(dict.fromkeys(_PATCH_PATH.findall(patch)))
147
+ if not paths:
148
+ raise TypeError("args.patchText names no file (expected `*** Update File: path` lines)")
149
+ return paths
150
+
151
+
152
+ def parse(payload: dict) -> Call | list[Call] | None:
153
+ """What the shim sends: {"tool": "bash", "args": {"command": ...}}. A patch tool is a write of each file in the
154
+ patch."""
136
155
  tool, args = payload.get("tool"), payload.get("args")
137
156
  if not isinstance(tool, str) or not isinstance(args, dict):
138
157
  raise TypeError("payload has no tool and args")
@@ -142,6 +161,15 @@ def parse(payload: dict) -> Call | None:
142
161
  return Call("read", _text(args, "filePath"))
143
162
  if tool in ("edit", "write", "multiedit"):
144
163
  return Call("write", _text(args, "filePath"))
164
+ if tool in ("patch", "apply_patch"):
165
+ kept = payload.get(
166
+ "paths"
167
+ ) # only a line record-calls kept has these (the shim sends none): text -> fingerprint
168
+ if isinstance(args.get("patchText"), dict) and isinstance(kept, list) and kept:
169
+ if not all(isinstance(p, str) and p for p in kept):
170
+ raise TypeError("paths must be non-empty text")
171
+ return [Call.of("write", p, tool=tool) for p in kept]
172
+ return [Call.of("write", p, tool=tool) for p in patch_paths(_text(args, "patchText"))]
145
173
  if tool in ("grep", "glob", "list"):
146
174
  path = args.get("path")
147
175
  if path is not None and not isinstance(path, str):
@@ -15,7 +15,9 @@ import { resolve } from "node:path"
15
15
 
16
16
  const CHECK = /*__CHECK__*/ []
17
17
  const WS = /*__WS__*/ []
18
- const GOVERNED = new Set(["bash", "read", "grep", "glob", "list", "edit", "write", "multiedit", "webfetch", "websearch"])
18
+ const GOVERNED = new Set([
19
+ "bash", "read", "grep", "glob", "list", "edit", "write", "multiedit", "patch", "apply_patch", "webfetch", "websearch",
20
+ ])
19
21
 
20
22
  // Session notes (advice, never a gate): record the release on session.created; on session.idle ask `ws session nudge`
21
23
  // and show its one line as a toast (OpenCode has no way for a plugin to add text to the model's context). Any error is
@@ -69,6 +69,7 @@ from ws.profile import ws_home
69
69
 
70
70
  TTL = 600 # seconds a pending or a granted approval lives
71
71
  ENV_MARKER = "WS_GATED_SESSION" # set in a gated session's env; `ws approve` refuses when it is there
72
+ SANDBOX_MARKER = "WS_SANDBOXED" # set in the env of a session that has ws's sandbox block (the hook checks for it)
72
73
  _SHORT = 12 # hex digits of the id shown to a person
73
74
  _ID = re.compile(r"^[0-9a-f]{8,64}$")
74
75
  _FULL = re.compile(r"^[0-9a-f]{64}$")
@@ -249,10 +250,13 @@ def peek(root: Path, ident: str, now: float) -> str | None:
249
250
  return _verdict(text, ident, now)
250
251
 
251
252
 
252
- def require(root: Path, bound: Bound, now: float | None = None, consume: bool = True) -> str | Claim | None:
253
+ def require(
254
+ root: Path, bound: Bound, now: float | None = None, consume: bool = True, why_asked: str = ""
255
+ ) -> str | Claim | None:
253
256
  """None if this exact call holds a granted approval (which is used up). Else the reason it is refused, with the
254
257
  id to approve: a pending record is written (or kept, if one is still fresh). With consume=False (inside the
255
- wall, where the grant cannot be consumed) a valid grant gives a Claim for the caller to send to the launcher."""
258
+ wall, where the grant cannot be consumed) a valid grant gives a Claim for the caller to send to the launcher.
259
+ `why_asked` goes in front of the reason: what stopped the call (a guard's finding)."""
256
260
  now = time.time() if now is None else now
257
261
  root = Path(root)
258
262
  ident = bound.digest()
@@ -269,6 +273,7 @@ def require(root: Path, bound: Bound, now: float | None = None, consume: bool =
269
273
  _prune(root, now)
270
274
  note = "" if why == _NONE else f" ({why})"
271
275
  return (
276
+ f"{why_asked + ': ' if why_asked else ''}"
272
277
  f"needs a person's approval{note}: in a terminal of your own run `ws approve {ident[:_SHORT]}`, "
273
278
  f"then retry the identical call (good for one use, within {TTL // 60} minutes)"
274
279
  )
@@ -348,7 +348,8 @@ class Broker:
348
348
  allow_plain_http: bool = False,
349
349
  ):
350
350
  env = os.environ if env is None else env
351
- sock = Path(socket_file).expanduser() if socket_file else ws_home(env) / "run" / "broker.sock"
351
+ # --socket wins; else what clients use: $WS_BROKER_SOCKET, [roles.secrets] socket, $WS_HOME/run/broker.sock
352
+ sock = Path(socket_file).expanduser() if socket_file else wsecrets.socket_path(env)
352
353
  return cls(
353
354
  sock,
354
355
  wsecrets.registry_path(env),
@@ -978,7 +979,11 @@ def register(sub) -> None:
978
979
  b = sub.add_parser("broker", help="the secrets broker: agents ask it to use a key and never see the value")
979
980
  bsub = b.add_subparsers(dest="broker_command", required=True)
980
981
  p = bsub.add_parser("serve", help="run the broker (a Unix socket and a loopback lease proxy)")
981
- p.add_argument("--socket", metavar="PATH", help="default $WS_HOME/run/broker.sock")
982
+ p.add_argument(
983
+ "--socket",
984
+ metavar="PATH",
985
+ help="default $WS_BROKER_SOCKET, else [roles.secrets] socket in the profile, else $WS_HOME/run/broker.sock",
986
+ )
982
987
  p.add_argument("--group", metavar="GROUP", help="also serve members of this group (socket folder becomes 0750)")
983
988
  p.add_argument("--port", type=int, default=0, help="lease proxy port on 127.0.0.1 (default: any free one)")
984
989
  p.add_argument(
@@ -15,6 +15,7 @@ from ws import (
15
15
  config_cmd,
16
16
  devices,
17
17
  doctor,
18
+ extcmd,
18
19
  gate,
19
20
  install,
20
21
  mcp_server,
@@ -217,10 +218,19 @@ def cmd_help(args: argparse.Namespace) -> int:
217
218
  if word not in kids:
218
219
  raise WsError("usage", f"no help topic {word!r}", output.did_you_mean(word, kids) or "run `ws help`", 2)
219
220
  parser = kids[word]
221
+ root = parser is args.ws_root
222
+ extra = extcmd.help_lines(_subcommands(parser)) if root else []
220
223
  if args.json:
221
- output.result(_help_json(parser))
224
+ data = _help_json(parser)
225
+ if root:
226
+ builtins = _subcommands(parser)
227
+ data["external"] = extcmd.list_externals(builtins)
228
+ data["fallback"] = extcmd._fallback_for_help()
229
+ output.result(data)
222
230
  else:
223
231
  print(parser.format_help(), end="")
232
+ if extra:
233
+ print("\n" + "\n".join(extra))
224
234
  return 0
225
235
 
226
236
 
@@ -267,6 +277,7 @@ def build_parser() -> argparse.ArgumentParser:
267
277
  approvals.register(sub)
268
278
  setup_ui.register(sub)
269
279
  config_cmd.register(sub)
280
+ extcmd.register(sub)
270
281
  status.register(sub)
271
282
  ws_eval.register(sub)
272
283
  version.register(sub)
@@ -279,9 +290,14 @@ def main(argv: list[str] | None = None) -> int:
279
290
  try:
280
291
  g, rest = output.split_global(raw)
281
292
  want_json = g.json
282
- if g.cwd is not None:
293
+ parser = build_parser()
294
+ flags = [o for a in parser._actions for o in a.option_strings]
295
+ spec = extcmd.plan(raw, g, rest, _subcommands(parser), flags)
296
+ if g.cwd is not None and (spec is None or spec.keep_cwd):
283
297
  output.enter_cwd(g.cwd)
284
- args = build_parser().parse_args(rest)
298
+ if spec is not None:
299
+ return extcmd.launch(spec)
300
+ args = parser.parse_args(rest)
285
301
  kind = args.ws_command
286
302
  return output.dispatch(args, kind, g.json)
287
303
  except WsError as e: