keywright 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keywright/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Keywright client: `kw` for agents, the hazard tripwire, and agent-machine setup."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,29 @@
1
+ """Private MCP worker. Its stdout is a JSON-lines pipe owned by transport.acall."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+
7
+ from .config import Profile
8
+ from .transport import NETWORK_HINT, AuthError, TransportError, _call
9
+
10
+
11
+ def emit(kind, data):
12
+ print(json.dumps([kind, data]), flush=True)
13
+
14
+
15
+ def main():
16
+ try:
17
+ request = json.load(sys.stdin)
18
+ asyncio.run(_call(Profile(**request["profile"]), request["session"], request["tool"],
19
+ request["arguments"], request["read_timeout"], emit))
20
+ except AuthError as e:
21
+ emit("auth", str(e))
22
+ except TransportError as e:
23
+ emit("error", str(e))
24
+ except Exception as e: # noqa: BLE001 - never leak raw exceptions across the worker boundary
25
+ emit("error", f"{type(e).__name__}; {NETWORK_HINT}")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
keywright/classify.py ADDED
@@ -0,0 +1,185 @@
1
+ """Turn a shell command line into the permission paths it needs.
2
+
3
+ This is a scope guard, not a sandbox: it exists so an agent that asked for
4
+ `journalctl` doesn't quietly run `rm -rf`. Anything the parser can't reason
5
+ about safely (command substitution, eval, `bash -c`, xargs ...) requires the
6
+ host-wide pattern `ssh/<host>/exec/**`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shlex
13
+ from typing import Any
14
+
15
+ from .patterns import qualifier
16
+
17
+ WRAPPERS_WITH_ARGS = {"sudo", "env", "nice", "timeout", "nohup", "time", "command", "builtin", "stdbuf", "ionice"}
18
+ # bins whose first subcommand meaningfully changes what they do
19
+ SUBCOMMAND_BINS = {
20
+ "docker", "podman", "systemctl", "git", "qm", "pct", "pvesh", "pvesm", "pveum", "apt", "apt-get",
21
+ "kubectl", "ha", "npm", "pip", "uv", "zfs", "zpool", "ceph", "rados", "rbd", "virsh", "nmcli",
22
+ "ip", "ufw", "iptables", "nft", "snap", "brew", "gh", "vzdump", "go", "cargo", "deno", "pnpm", "yarn", "bun",
23
+ "poetry",
24
+ }
25
+ TWO_LEVEL = {("docker", "compose"), ("docker", "container"), ("docker", "image"), ("docker", "volume"),
26
+ ("docker", "network"), ("podman", "compose"), ("git", "remote"), ("git", "stash"), ("ip", "link"),
27
+ ("ip", "addr"), ("ip", "route")}
28
+ # Flags that may sit between a two-level bin and its real subcommand (`docker compose -f x.yml up`).
29
+ # Only listed flags are skipped: an unknown flag might take a value that looks like a subcommand.
30
+ _COMPOSE_VALUE_FLAGS = {"-f", "--file", "-p", "--project-name", "--project-directory", "--env-file", "--profile",
31
+ "--ansi", "--parallel", "--progress"}
32
+ _COMPOSE_BOOL_FLAGS = {"--compatibility", "--dry-run", "--all-resources"}
33
+ LEVEL_FLAGS = {("docker", "compose"): (_COMPOSE_VALUE_FLAGS, _COMPOSE_BOOL_FLAGS),
34
+ ("podman", "compose"): (_COMPOSE_VALUE_FLAGS, _COMPOSE_BOOL_FLAGS)}
35
+ SHELLS = {"bash", "sh", "zsh", "dash", "fish", "ksh", "eval", "exec", "source", ".", "xargs", "python", "python3",
36
+ "perl", "ruby", "node", "su", "screen", "tmux", "ssh", "script", "at", "batch", "crontab", "watch"}
37
+ # Bins that run project code by design. Bare names always fail closed; the others only for the
38
+ # subcommands that execute something (`go version` stays narrow, `go test` does not).
39
+ RUNNERS = {"pytest", "py.test", "tox", "nox", "mvn", "gradle", "rake", "npx", "uvx", "pipx", "ansible",
40
+ "ansible-playbook", "terraform", "tofu", "pulumi"}
41
+ RUNNER_SUBCOMMANDS = {
42
+ "npm": {"run", "test", "start", "exec", "x", "create", "init", "publish"},
43
+ "pnpm": {"run", "test", "start", "exec", "dlx", "create", "init", "publish"},
44
+ "yarn": {"run", "test", "start", "exec", "dlx", "create", "init", "publish"},
45
+ "bun": {"run", "test", "x", "create", "init", "publish"},
46
+ "go": {"run", "test", "generate", "tool", "vet"},
47
+ "cargo": {"run", "test", "bench", "build", "install", "publish", "xtask"},
48
+ "deno": {"run", "test", "task", "eval", "compile", "repl", "bench"},
49
+ "poetry": {"run", "shell"}, "pdm": {"run"}, "hatch": {"run", "shell", "test"}, "uv": {"run"}, "pip": {"install"},
50
+ }
51
+ SEPARATORS = {";", "|", "||", "&&", "&", "(", ")", "{", "}", "|&"}
52
+ WILD = "**"
53
+
54
+
55
+ def classify(host: str, command: str) -> list[str]:
56
+ """Return the ordered, de-duplicated list of required patterns for `command` on `host`."""
57
+ base = f"ssh/{host}/exec"
58
+ # shlex is a tokenizer, not a shell parser. Fail closed for syntax which can
59
+ # change execution, environment, privileges, or write files outside the named command.
60
+ if any(c in command for c in "\n\r<>$`#\\"):
61
+ return [f"{base}/{WILD}"]
62
+ try:
63
+ lex = shlex.shlex(command, posix=True, punctuation_chars=";|&()<>{}")
64
+ lex.whitespace_split = True
65
+ tokens = list(lex)
66
+ except ValueError:
67
+ return [f"{base}/{WILD}"]
68
+
69
+ segments: list[list[str]] = [[]]
70
+ for tok in tokens:
71
+ if tok in SEPARATORS:
72
+ if segments[-1]:
73
+ segments.append([])
74
+ continue
75
+ segments[-1].append(tok)
76
+ if not segments[-1]:
77
+ segments.pop()
78
+
79
+ out: list[str] = []
80
+ for seg in segments:
81
+ pat = _classify_segment(base, seg)
82
+ if pat not in out:
83
+ out.append(pat)
84
+ if not out:
85
+ out.append(f"{base}/{WILD}")
86
+ if f"{base}/{WILD}" in out:
87
+ return [f"{base}/{WILD}"]
88
+ return out
89
+
90
+
91
+ def _classify_segment(base: str, words: list[str]) -> str:
92
+ if not words:
93
+ return f"{base}/{WILD}"
94
+ executable = words[0]
95
+ if "=" in executable or executable.rsplit("/", 1)[-1] in WRAPPERS_WITH_ARGS:
96
+ return f"{base}/{WILD}"
97
+ # Absolute system paths are useful; any other path could be an agent's own executable.
98
+ if "/" in executable and executable not in ("/usr/bin/" + executable.rsplit("/", 1)[-1],
99
+ "/bin/" + executable.rsplit("/", 1)[-1],
100
+ "/usr/sbin/" + executable.rsplit("/", 1)[-1],
101
+ "/sbin/" + executable.rsplit("/", 1)[-1]):
102
+ return f"{base}/{WILD}"
103
+ bin_ = executable.rsplit("/", 1)[-1]
104
+ if bin_ in SHELLS or bin_ in {"if", "then", "for", "while", "until", "case", "function", "!"} or re.fullmatch(r"(?:python|pypy|node|perl|ruby|lua)[0-9.]*", bin_):
105
+ return f"{base}/{WILD}"
106
+ if bin_ == "find" and any(w in ("-exec", "-execdir", "-delete", "-ok", "-okdir") for w in words):
107
+ return f"{base}/{WILD}"
108
+ if bin_ in {"awk", "gawk", "mawk", "sed", "make", "gmake", "less", "more", "vi", "vim", "nvim"}:
109
+ return f"{base}/{WILD}"
110
+ # Test runners and build tools execute whatever the project contains; `exec/pytest` would read
111
+ # as narrow in a prompt while meaning anything at all.
112
+ if bin_ in RUNNERS or (bin_ in RUNNER_SUBCOMMANDS and any(w in RUNNER_SUBCOMMANDS[bin_] for w in words[1:2])):
113
+ return f"{base}/{WILD}"
114
+ if bin_ == "git" and any(w == "-c" or w.startswith(("-c", "--config-env", "--exec-path")) for w in words[1:]):
115
+ return f"{base}/{WILD}"
116
+ if not _is_word(bin_):
117
+ return f"{base}/{WILD}"
118
+ parts = [bin_]
119
+ if bin_ in SUBCOMMAND_BINS:
120
+ args = words[1:]
121
+ if bin_ == "git" and len(args) >= 3 and args[0] == "-C":
122
+ args = args[2:]
123
+ if not args or args[0].startswith("-"):
124
+ return f"{base}/{WILD}"
125
+ sub = args[0] if _is_word(args[0]) else None
126
+ if sub:
127
+ parts.append(sub)
128
+ if (bin_, sub) in TWO_LEVEL:
129
+ rest = _skip_flags(args[1:], *LEVEL_FLAGS.get((bin_, sub), (set(), set())))
130
+ if rest is None or not rest or not _is_word(rest[0]):
131
+ return f"{base}/{WILD}"
132
+ parts.append(rest[0])
133
+ return f"{base}/" + "/".join(parts)
134
+
135
+
136
+ def _skip_flags(args: list[str], value_flags: set[str], bool_flags: set[str]) -> list[str] | None:
137
+ """Drop leading known flags (and their values); None when an unknown flag is in the way."""
138
+ i = 0
139
+ while i < len(args) and args[i].startswith("-"):
140
+ flag, eq, _ = args[i].partition("=")
141
+ if flag in value_flags:
142
+ i += 1 if eq else 2 # --file=x is one token; -f x is two
143
+ elif flag in bool_flags and not eq:
144
+ i += 1
145
+ else:
146
+ return None
147
+ return args[i:]
148
+
149
+
150
+ def _is_word(w: str) -> bool:
151
+ return bool(w) and all(c.isalnum() or c in "_.-+:" for c in w)
152
+
153
+
154
+ def classify_mcp(server: str, tool: str, arguments: dict[str, Any] | None, keys: list[str] | None) -> list[str]:
155
+ """Permission paths for one upstream MCP call.
156
+
157
+ `keys` are dotted paths into the arguments the resource owner cares about (`service`,
158
+ `data.entity_id`). Each key present in the call adds `mcp/<server>/tools/<tool>/<key>=<value>`;
159
+ a list value adds one path per element. Every path must be allowed, so a tool-level grant
160
+ (literal prefix) covers everything and a deny on one qualifier blocks just that value.
161
+ With no keys configured, or none present in the call, the bare tool path is required.
162
+ """
163
+ base = f"mcp/{server}/tools/{tool}"
164
+ paths: list[str] = []
165
+ for key in keys or []:
166
+ value = _lookup(arguments or {}, key)
167
+ if value is None:
168
+ continue
169
+ values = value if isinstance(value, list) else [value]
170
+ for v in values:
171
+ if isinstance(v, (dict, list)):
172
+ continue
173
+ q = f"{base}/{qualifier(key, v)}"
174
+ if q not in paths:
175
+ paths.append(q)
176
+ return paths or [base]
177
+
178
+
179
+ def _lookup(arguments: dict[str, Any], key: str) -> Any:
180
+ cur: Any = arguments
181
+ for part in key.split("."):
182
+ if not isinstance(cur, dict) or part not in cur:
183
+ return None
184
+ cur = cur[part]
185
+ return cur