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.
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/PKG-INFO +10 -1
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/README.md +9 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/pyproject.toml +1 -1
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/pyproject.toml.orig +1 -1
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/_build.py +2 -2
- ws_aitoolkit-0.1.2/src/ws/adapters/__init__.py +99 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/adapters/claude_code.py +164 -22
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/adapters/opencode.py +34 -6
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/adapters/opencode_plugin.py +3 -1
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/approvals.py +7 -2
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/broker.py +7 -2
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/cli.py +19 -3
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/doctor.py +13 -2
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/eval.py +44 -4
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/evalstats.py +37 -0
- ws_aitoolkit-0.1.2/src/ws/extcmd.py +293 -0
- ws_aitoolkit-0.1.2/src/ws/gate.py +390 -0
- ws_aitoolkit-0.1.2/src/ws/guards/__init__.py +204 -0
- ws_aitoolkit-0.1.2/src/ws/guards/_paths.py +52 -0
- ws_aitoolkit-0.1.2/src/ws/guards/bulk_staging.py +218 -0
- ws_aitoolkit-0.1.2/src/ws/guards/claims.py +228 -0
- ws_aitoolkit-0.1.2/src/ws/guards/record_calls.py +100 -0
- ws_aitoolkit-0.1.2/src/ws/guards/session_context.py +86 -0
- ws_aitoolkit-0.1.2/src/ws/guards/skill_gate.py +98 -0
- ws_aitoolkit-0.1.2/src/ws/guards/spawn_cap.py +93 -0
- ws_aitoolkit-0.1.2/src/ws/guards/syntax_check.py +129 -0
- ws_aitoolkit-0.1.2/src/ws/guards/test_weakening.py +238 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/install.py +130 -4
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/modules.py +1 -1
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/policy.py +337 -26
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/commands.py +52 -5
- ws_aitoolkit-0.1.2/src/ws/roles/ntfy.py +500 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/secrets.py +103 -0
- ws_aitoolkit-0.1.2/src/ws/selfcmd.py +55 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/session.py +8 -4
- ws_aitoolkit-0.1.2/src/ws/skills/__init__.py +460 -0
- ws_aitoolkit-0.1.2/src/ws/skills/resume.py +55 -0
- ws_aitoolkit-0.1.2/src/ws/skills/ship.py +56 -0
- ws_aitoolkit-0.1.2/src/ws/userconfig.py +897 -0
- ws_aitoolkit-0.1.1/src/ws/adapters/__init__.py +0 -68
- ws_aitoolkit-0.1.1/src/ws/gate.py +0 -210
- ws_aitoolkit-0.1.1/src/ws/roles/ntfy.py +0 -287
- ws_aitoolkit-0.1.1/src/ws/selfcmd.py +0 -19
- ws_aitoolkit-0.1.1/src/ws/skills/__init__.py +0 -191
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/LICENSE +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/NOTICE +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/__init__.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/__main__.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/canary.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/config_cmd.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/devices.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/frontmatter.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/globals.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/jobs.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/jsonl.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/mcp_server.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/memory.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/notes.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/notesdetect.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/osinfo.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/output.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/profile.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/rating.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/__init__.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/_schedule.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/anthropic.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/launchd.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/markdown_git.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/notify_none.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/opencode.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/restic.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/secrets_env.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/secrets_ws.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/sops_age.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/roles/systemd.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/run.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/runrecord.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/runschema.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/sandbox.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/__init__.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/brand.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/flow.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/inline.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/setup_ui/web.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/align.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/checkpoint.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/dataviz.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/skills/taste_gate.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/status.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/testing/__init__.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/testing/mockprovider.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/testing/scenarios.py +0 -0
- {ws_aitoolkit-0.1.1 → ws_aitoolkit-0.1.2}/src/ws/usage.py +0 -0
- {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.
|
|
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
|
"""Written by the release workflow before uv build; absent in a checkout."""
|
|
2
2
|
|
|
3
|
-
git_sha = "
|
|
3
|
+
git_sha = "92389d65eb2553fe9d570d9fd6f37cb386b08c01"
|
|
4
4
|
dirty = False
|
|
5
|
-
build_date = "2026-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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":
|
|
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(
|
|
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
|
-
|
|
135
|
-
|
|
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([
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|