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 +3 -0
- keywright/_transport_worker.py +29 -0
- keywright/classify.py +185 -0
- keywright/cli.py +748 -0
- keywright/config.py +105 -0
- keywright/hazards.py +948 -0
- keywright/hook.py +161 -0
- keywright/patterns.py +153 -0
- keywright/session.py +144 -0
- keywright/setup/__init__.py +2 -0
- keywright/setup/check.py +115 -0
- keywright/setup/claude_code.py +176 -0
- keywright/setup/codex.py +62 -0
- keywright/setup/common.py +146 -0
- keywright/skill/SKILL.md +134 -0
- keywright/transport.py +182 -0
- keywright-0.0.1.dist-info/METADATA +77 -0
- keywright-0.0.1.dist-info/RECORD +23 -0
- keywright-0.0.1.dist-info/WHEEL +4 -0
- keywright-0.0.1.dist-info/entry_points.txt +3 -0
- keywright-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
keywright/hook.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Claude Code hooks, stdlib only.
|
|
2
|
+
|
|
3
|
+
PreToolUse on Bash: the hazard tripwire over the whole line, with one refinement for lines that
|
|
4
|
+
invoke `kw` itself. The part of a `kw run/put/get/capture` line that is the remote payload is
|
|
5
|
+
the server's to judge (it is flagged there, prompted or refused there), so here it only earns a
|
|
6
|
+
note. Everything local on the same line (other segments, substitutions, redirections, `get -o`
|
|
7
|
+
destinations) is enforced as usual. And `kw setup|login|logout|session-start` always asks,
|
|
8
|
+
whatever options precede the subcommand: an agent must not rewrite its own gate.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import shlex
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .hazards import Hazard, blocks, describe, scan_command, scan_path, worst
|
|
18
|
+
|
|
19
|
+
CLI_NAMES = {"kw", "keywright"}
|
|
20
|
+
GUARDED = {"setup", "login", "logout", "session-start"}
|
|
21
|
+
REMOTE_COMMAND = {"run", "capture"}
|
|
22
|
+
GLOBAL_VALUE_OPTS = {"--profile", "-p"}
|
|
23
|
+
_SEPARATORS = {";", "|", "||", "&&", "&", "(", ")"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tokens(command: str) -> list[str] | None:
|
|
27
|
+
try:
|
|
28
|
+
lex = shlex.shlex(command, posix=True, punctuation_chars=";|&()<>")
|
|
29
|
+
lex.whitespace_split = True
|
|
30
|
+
return list(lex)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _segments(tokens: list[str]) -> list[list[str]]:
|
|
36
|
+
segs: list[list[str]] = [[]]
|
|
37
|
+
for tok in tokens:
|
|
38
|
+
if tok in _SEPARATORS:
|
|
39
|
+
if segs[-1]:
|
|
40
|
+
segs.append([])
|
|
41
|
+
else:
|
|
42
|
+
segs[-1].append(tok)
|
|
43
|
+
return [s for s in segs if s]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_invocation(words: list[str]) -> dict[str, Any] | None:
|
|
47
|
+
"""For a segment that invokes kw: {sub, host, remote, local_writes, local_reads}. None otherwise."""
|
|
48
|
+
if not words or words[0].rsplit("/", 1)[-1] not in CLI_NAMES:
|
|
49
|
+
return None
|
|
50
|
+
i = 1
|
|
51
|
+
while i < len(words) and words[i].startswith("-"):
|
|
52
|
+
if words[i] in GLOBAL_VALUE_OPTS and "=" not in words[i]:
|
|
53
|
+
i += 2
|
|
54
|
+
else:
|
|
55
|
+
i += 1
|
|
56
|
+
if i >= len(words):
|
|
57
|
+
return {"sub": None, "remote": [], "local_writes": [], "local_reads": []}
|
|
58
|
+
sub, rest = words[i], words[i + 1:]
|
|
59
|
+
out: dict[str, Any] = {"sub": sub, "remote": [], "local_writes": [], "local_reads": []}
|
|
60
|
+
if sub in REMOTE_COMMAND:
|
|
61
|
+
if "--" in rest:
|
|
62
|
+
k = rest.index("--")
|
|
63
|
+
out["remote"].append(shlex.join(rest[k + 1:]))
|
|
64
|
+
rest = rest[:k]
|
|
65
|
+
for j, w in enumerate(rest):
|
|
66
|
+
if w in ("-c", "--command") and j + 1 < len(rest):
|
|
67
|
+
out["remote"].append(rest[j + 1])
|
|
68
|
+
if w.startswith(("-c=", "--command=")):
|
|
69
|
+
out["remote"].append(w.split("=", 1)[1])
|
|
70
|
+
positional = [w for w in rest if not w.startswith("-")]
|
|
71
|
+
if sub == "put" and len(positional) >= 2:
|
|
72
|
+
out["remote_write"] = positional[1]
|
|
73
|
+
for j, w in enumerate(rest):
|
|
74
|
+
if w in ("--file", "-f") and j + 1 < len(rest):
|
|
75
|
+
out["local_reads"].append(rest[j + 1])
|
|
76
|
+
if sub == "get":
|
|
77
|
+
if len(positional) >= 2:
|
|
78
|
+
out["remote_read"] = positional[1]
|
|
79
|
+
for j, w in enumerate(rest):
|
|
80
|
+
if w in ("-o", "--output") and j + 1 < len(rest):
|
|
81
|
+
out["local_writes"].append(rest[j + 1])
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyze(command: str) -> tuple[list[Hazard], list[Hazard], bool]:
|
|
86
|
+
"""(local hazards, server-gated hazards, guarded-subcommand present)."""
|
|
87
|
+
tokens = _tokens(command)
|
|
88
|
+
if tokens is None:
|
|
89
|
+
return scan_command(command), [], False
|
|
90
|
+
segments = _segments(tokens)
|
|
91
|
+
if not any(s[0].rsplit("/", 1)[-1] in CLI_NAMES for s in segments):
|
|
92
|
+
return scan_command(command), [], False
|
|
93
|
+
local_parts: list[str] = []
|
|
94
|
+
remote: list[Hazard] = []
|
|
95
|
+
local_extra: list[Hazard] = []
|
|
96
|
+
guarded = False
|
|
97
|
+
for seg in segments:
|
|
98
|
+
inv = parse_invocation(seg)
|
|
99
|
+
if inv is None:
|
|
100
|
+
local_parts.append(shlex.join(seg))
|
|
101
|
+
continue
|
|
102
|
+
if inv["sub"] in GUARDED:
|
|
103
|
+
guarded = True
|
|
104
|
+
for r in inv["remote"]:
|
|
105
|
+
remote.extend(scan_command(r))
|
|
106
|
+
if inv.get("remote_write"):
|
|
107
|
+
remote.extend(scan_path(inv["remote_write"], "write"))
|
|
108
|
+
if inv.get("remote_read"):
|
|
109
|
+
remote.extend(scan_path(inv["remote_read"], "read"))
|
|
110
|
+
for p in inv["local_writes"]:
|
|
111
|
+
local_extra.extend(scan_path(p, "write"))
|
|
112
|
+
for p in inv["local_reads"]:
|
|
113
|
+
local_extra.extend(scan_path(p, "read"))
|
|
114
|
+
# the invocation minus its payload still takes part in the local scan (cd, pipes around it)
|
|
115
|
+
local_parts.append(shlex.join([w for w in seg if w not in inv["remote"]]).split(" -- ")[0])
|
|
116
|
+
local = scan_command(" ; ".join(local_parts)) + [h for h in local_extra if h not in local_extra[:0]]
|
|
117
|
+
seen: list[Hazard] = []
|
|
118
|
+
for h in local:
|
|
119
|
+
if h not in seen:
|
|
120
|
+
seen.append(h)
|
|
121
|
+
return seen, remote, guarded
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def pre_tool_use(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
125
|
+
if payload.get("tool_name") != "Bash":
|
|
126
|
+
return None
|
|
127
|
+
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
128
|
+
if not command.strip():
|
|
129
|
+
return None
|
|
130
|
+
local, remote, guarded = analyze(command)
|
|
131
|
+
notes = []
|
|
132
|
+
if local:
|
|
133
|
+
notes.append("local " + describe(local))
|
|
134
|
+
if remote:
|
|
135
|
+
notes.append("server-gated " + describe(remote))
|
|
136
|
+
text = "Keywright hazard check: " + "; ".join(notes) if notes else ""
|
|
137
|
+
if guarded:
|
|
138
|
+
reason = "kw setup/login/logout change the agent's own gate; the human runs them."
|
|
139
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask",
|
|
140
|
+
"permissionDecisionReason": reason},
|
|
141
|
+
"systemMessage": reason + (" " + text if text else "")}
|
|
142
|
+
level = worst(local)
|
|
143
|
+
if level is None:
|
|
144
|
+
return {"systemMessage": text} if text else None
|
|
145
|
+
if not blocks(level):
|
|
146
|
+
return {"systemMessage": text}
|
|
147
|
+
decision = "deny" if level == "critical" else "ask"
|
|
148
|
+
reason = text + (". Refused: if this must happen, the user runs it by hand." if level == "critical"
|
|
149
|
+
else ". Flagged as destructive; an existing allow rule does not cover it.")
|
|
150
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision,
|
|
151
|
+
"permissionDecisionReason": reason},
|
|
152
|
+
"systemMessage": text}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_pre_tool_use(stdin_text: str) -> str:
|
|
156
|
+
try:
|
|
157
|
+
payload = json.loads(stdin_text)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return ""
|
|
160
|
+
out = pre_tool_use(payload) if isinstance(payload, dict) else None
|
|
161
|
+
return json.dumps(out) if out else ""
|
keywright/patterns.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Permission paths and glob patterns.
|
|
2
|
+
|
|
3
|
+
Paths are slash-separated: ssh/<host>/exec/<bin>[/<sub>...] mcp/<server>/tools/<tool>
|
|
4
|
+
llm/<gateway>/model/<model>
|
|
5
|
+
|
|
6
|
+
Pattern rules:
|
|
7
|
+
* matches exactly one segment
|
|
8
|
+
** matches zero or more segments (only meaningful as the last segment)
|
|
9
|
+
a pattern without wildcards also matches any path it is a segment-prefix of,
|
|
10
|
+
so granting `ssh/nvr/exec/docker` covers `ssh/nvr/exec/docker/ps`.
|
|
11
|
+
a segment may end in * to match a prefix: `entity_id=light.*` matches `entity_id=light.kitchen`.
|
|
12
|
+
|
|
13
|
+
MCP calls are qualified by argument values when the resource names argument keys:
|
|
14
|
+
mcp/<server>/tools/<tool>/<key>=<value> one path per configured key present in the call
|
|
15
|
+
so `mcp/ha/tools/call_service` (a literal prefix) still covers every call, while a deny on
|
|
16
|
+
`mcp/ha/tools/call_service/service=automation.turn_off` blocks only that service.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
PATTERN_HELP = (
|
|
24
|
+
"Pattern syntax: slash-separated segments, ssh/<host>/exec/<command>[/<sub>], "
|
|
25
|
+
"mcp/<server>/tools/<tool>[/<key>=<value>] or llm/<gateway>/model/<model>. `*` alone matches one whole segment; `**` matches "
|
|
26
|
+
"everything below and must be last; a segment may END in `*` to match a prefix (tools/ha_get_*, "
|
|
27
|
+
"entity_id=light.*) but `*` cannot appear elsewhere in a segment. A literal pattern also covers "
|
|
28
|
+
"everything under it. Allowed characters: letters, digits and _ . : @ + = , - (no spaces). Each pattern "
|
|
29
|
+
"must name one concrete resource: the first two segments cannot be wildcards."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
KINDS = ("ssh", "mcp", "llm")
|
|
33
|
+
|
|
34
|
+
_SEGMENT = re.compile(r"^[A-Za-z0-9_.:@+=,-]+\*?$|^\*$|^\*\*$")
|
|
35
|
+
_VALUE_CHARS = re.compile(r"[^A-Za-z0-9_.:@+=,-]")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate(pattern: str) -> str:
|
|
39
|
+
pattern = pattern.strip().strip("/")
|
|
40
|
+
if not pattern:
|
|
41
|
+
raise ValueError("empty pattern")
|
|
42
|
+
parts = pattern.split("/")
|
|
43
|
+
for i, seg in enumerate(parts):
|
|
44
|
+
if not _SEGMENT.match(seg):
|
|
45
|
+
raise ValueError(f"invalid segment {seg!r} in {pattern!r}")
|
|
46
|
+
if seg == "**" and i != len(parts) - 1:
|
|
47
|
+
raise ValueError("** is only allowed as the last segment")
|
|
48
|
+
if parts[0] not in KINDS and parts[0] not in ("*", "**"):
|
|
49
|
+
raise ValueError("pattern must start with ssh/, mcp/ or llm/")
|
|
50
|
+
return pattern
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def matches(pattern: str, path: str) -> bool:
|
|
54
|
+
p = pattern.strip("/").split("/")
|
|
55
|
+
t = path.strip("/").split("/")
|
|
56
|
+
if p and p[-1] == "**":
|
|
57
|
+
head = p[:-1]
|
|
58
|
+
return len(t) >= len(head) and all(_seg(a, b) for a, b in zip(head, t, strict=False))
|
|
59
|
+
if len(t) == len(p):
|
|
60
|
+
return all(_seg(a, b) for a, b in zip(p, t, strict=True))
|
|
61
|
+
# implicit prefix rule for literal (and value-glob) patterns
|
|
62
|
+
if len(t) > len(p) and not any(s in ("*", "**") for s in p):
|
|
63
|
+
return all(_seg(a, b) for a, b in zip(p, t, strict=False))
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _seg(pat: str, seg: str) -> bool:
|
|
68
|
+
if pat == "*" or pat == seg:
|
|
69
|
+
return True
|
|
70
|
+
return pat.endswith("*") and len(pat) > 1 and seg.startswith(pat[:-1])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_glob(seg: str) -> bool:
|
|
74
|
+
return seg.endswith("*") and len(seg) > 1 and seg != "**"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _seg_covers(allowed: str, required: str) -> bool:
|
|
78
|
+
"""Every concrete segment matched by `required` is matched by `allowed`."""
|
|
79
|
+
if allowed == "*" or allowed == required:
|
|
80
|
+
return True
|
|
81
|
+
if _is_glob(allowed):
|
|
82
|
+
base = allowed[:-1]
|
|
83
|
+
return required.startswith(base) if not _is_glob(required) else required[:-1].startswith(base)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _seg_overlaps(a: str, b: str) -> bool:
|
|
88
|
+
if a == "*" or b == "*" or a == b:
|
|
89
|
+
return True
|
|
90
|
+
pa, pb = (a[:-1] if _is_glob(a) else None), (b[:-1] if _is_glob(b) else None)
|
|
91
|
+
if pa is not None and pb is not None:
|
|
92
|
+
return pa.startswith(pb) or pb.startswith(pa)
|
|
93
|
+
if pa is not None:
|
|
94
|
+
return b.startswith(pa)
|
|
95
|
+
if pb is not None:
|
|
96
|
+
return a.startswith(pb)
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def qualifier(key: str, value: object) -> str:
|
|
101
|
+
"""`key=value` segment for an MCP argument. Characters outside the segment alphabet become `_`,
|
|
102
|
+
so a value can never smuggle a wildcard or a path separator into a required path."""
|
|
103
|
+
text = _VALUE_CHARS.sub("_", str(value))[:120] or "_"
|
|
104
|
+
return f"{key}={text}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resource_of(path: str) -> tuple[str, str] | None:
|
|
108
|
+
"""('ssh', 'nvr') for 'ssh/nvr/exec/ls'. None if the path is too short."""
|
|
109
|
+
parts = path.strip("/").split("/")
|
|
110
|
+
if len(parts) < 2 or parts[0] not in KINDS:
|
|
111
|
+
return None
|
|
112
|
+
return parts[0], parts[1]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def model_segment(model: str) -> str:
|
|
116
|
+
"""The path segment for an LLM model name. Provider prefixes such as `openrouter/anthropic/x`
|
|
117
|
+
contain slashes, which are path separators here, so every character outside the segment
|
|
118
|
+
alphabet becomes `_`: `llm/<gateway>/model/openrouter_anthropic_x`."""
|
|
119
|
+
return _VALUE_CHARS.sub("_", model)[:120] or "_"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def specificity(pattern: str) -> int:
|
|
123
|
+
"""Higher = more specific. Used to prefer narrow grants when several match."""
|
|
124
|
+
parts = pattern.strip("/").split("/")
|
|
125
|
+
score = 0
|
|
126
|
+
for s in parts:
|
|
127
|
+
score += 0 if s == "**" else 1 if s == "*" else 2 if _is_glob(s) else 3
|
|
128
|
+
return score
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _shape(pattern: str) -> tuple[list[str], bool]:
|
|
132
|
+
parts = pattern.strip("/").split("/")
|
|
133
|
+
if parts[-1] == "**":
|
|
134
|
+
return parts[:-1], True
|
|
135
|
+
return parts, not any(p == "*" for p in parts)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def overlaps(left: str, right: str) -> bool:
|
|
139
|
+
"""Whether two scopes share any concrete path (including implicit literal prefixes)."""
|
|
140
|
+
a, a_tail = _shape(left)
|
|
141
|
+
b, b_tail = _shape(right)
|
|
142
|
+
if (len(a) < len(b) and not a_tail) or (len(b) < len(a) and not b_tail):
|
|
143
|
+
return False
|
|
144
|
+
return all(_seg_overlaps(x, y) for x, y in zip(a, b, strict=False))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def covers(allowed: str, required: str) -> bool:
|
|
148
|
+
"""Whether the entire required scope is contained in the allowed scope."""
|
|
149
|
+
a, a_tail = _shape(allowed)
|
|
150
|
+
b, b_tail = _shape(required)
|
|
151
|
+
if len(a) > len(b) or (not a_tail and (b_tail or len(a) != len(b))):
|
|
152
|
+
return False
|
|
153
|
+
return all(_seg_covers(x, y) for x, y in zip(a, b, strict=False))
|
keywright/session.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Which conversation this call belongs to.
|
|
2
|
+
|
|
3
|
+
The server scopes "This session" approvals to whatever the client names in X-Keywright-Session,
|
|
4
|
+
and Claude Code's stateless MCP transport names nothing. The CLI names it, in this order:
|
|
5
|
+
|
|
6
|
+
1. KW_SESSION in the environment: automation can set `job:<id>`, Claude Code gets
|
|
7
|
+
`claude:<session_id>` from the SessionStart hook (`kw session-start --hook`) through
|
|
8
|
+
CLAUDE_ENV_FILE. This is the preferred path because it is per conversation.
|
|
9
|
+
2. CODEX_THREAD_ID when supplied by Codex: a conversation identity that survives hidden
|
|
10
|
+
process ancestry in a sandbox. Only a bounded, header-safe value is accepted.
|
|
11
|
+
3. A per-agent-process fallback: a file named by the nearest non-shell ancestor's
|
|
12
|
+
(pid, start time, boot id), created with O_EXCL and holding a random id. PID reuse cannot
|
|
13
|
+
revive an old identity because the start time differs; stale files are swept on every run.
|
|
14
|
+
4. None: the server then offers no "This session" scope, as before the CLI existed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SHELLS = {"bash", "sh", "zsh", "dash", "fish", "ksh", "sudo", "env", "timeout", "nohup", "script", "python3",
|
|
26
|
+
"python"}
|
|
27
|
+
AGENT_HINTS = {"claude": "claude", "codex": "codex", "cephloom": "cephloom"}
|
|
28
|
+
MAX_LEN = 128
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def runtime_dir() -> Path:
|
|
32
|
+
base = os.environ.get("XDG_RUNTIME_DIR")
|
|
33
|
+
if base and os.access(base, os.W_OK):
|
|
34
|
+
return Path(base) / "keywright"
|
|
35
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")) / "keywright" / "run"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve() -> tuple[str | None, str]:
|
|
39
|
+
"""(session value or None, how it was found)."""
|
|
40
|
+
env = os.environ.get("KW_SESSION")
|
|
41
|
+
if env:
|
|
42
|
+
return env[:MAX_LEN], "KW_SESSION"
|
|
43
|
+
thread = os.environ.get("CODEX_THREAD_ID", "")
|
|
44
|
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,121}", thread):
|
|
45
|
+
return f"codex:{thread}", "CODEX_THREAD_ID"
|
|
46
|
+
if not sys.platform.startswith("linux"):
|
|
47
|
+
return None, "none (no /proc on this platform)"
|
|
48
|
+
anchor = _agent_ancestor()
|
|
49
|
+
if anchor is None:
|
|
50
|
+
return None, "none (no agent process found above this shell)"
|
|
51
|
+
pid, start, kind = anchor
|
|
52
|
+
try:
|
|
53
|
+
value = _per_process(pid, start, kind)
|
|
54
|
+
except OSError as e:
|
|
55
|
+
return None, f"none ({e})"
|
|
56
|
+
return value, f"process {pid} ({kind})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _proc_field(pid: int) -> tuple[int, str, str] | None:
|
|
60
|
+
"""(ppid, comm, starttime) from /proc/<pid>/stat, or None when the process is gone."""
|
|
61
|
+
try:
|
|
62
|
+
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
# comm may contain spaces; it is bracketed
|
|
66
|
+
comm = stat[stat.index("(") + 1:stat.rindex(")")]
|
|
67
|
+
rest = stat[stat.rindex(")") + 2:].split()
|
|
68
|
+
return int(rest[1]), comm, rest[19]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmdline(pid: int) -> str:
|
|
72
|
+
try:
|
|
73
|
+
return Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
|
74
|
+
except OSError:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _agent_ancestor() -> tuple[int, str, str] | None:
|
|
79
|
+
pid = os.getppid()
|
|
80
|
+
for _ in range(64):
|
|
81
|
+
if pid <= 1:
|
|
82
|
+
return None
|
|
83
|
+
info = _proc_field(pid)
|
|
84
|
+
if info is None:
|
|
85
|
+
return None
|
|
86
|
+
ppid, comm, start = info
|
|
87
|
+
if comm not in SHELLS:
|
|
88
|
+
line = _cmdline(pid).lower()
|
|
89
|
+
kind = next((k for k, needle in AGENT_HINTS.items() if needle in comm.lower() or needle in line), "shell")
|
|
90
|
+
return pid, start, kind
|
|
91
|
+
pid = ppid
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _boot_id() -> str:
|
|
96
|
+
try:
|
|
97
|
+
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()[:8]
|
|
98
|
+
except OSError:
|
|
99
|
+
return "noboot"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _per_process(pid: int, start: str, kind: str) -> str:
|
|
103
|
+
d = runtime_dir()
|
|
104
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
os.chmod(d, 0o700)
|
|
106
|
+
_sweep(d)
|
|
107
|
+
path = d / f"{pid}-{start}-{_boot_id()}"
|
|
108
|
+
try:
|
|
109
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
110
|
+
except FileExistsError:
|
|
111
|
+
return path.read_text().strip()
|
|
112
|
+
value = f"{kind}:{secrets.token_urlsafe(12)}"
|
|
113
|
+
with os.fdopen(fd, "w") as f:
|
|
114
|
+
f.write(value)
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _sweep(d: Path) -> None:
|
|
119
|
+
boot = _boot_id()
|
|
120
|
+
for f in d.iterdir():
|
|
121
|
+
parts = f.name.split("-")
|
|
122
|
+
if len(parts) != 3:
|
|
123
|
+
continue
|
|
124
|
+
pid, start, b = parts
|
|
125
|
+
info = _proc_field(int(pid)) if pid.isdigit() else None
|
|
126
|
+
if b != boot or info is None or info[2] != start:
|
|
127
|
+
try:
|
|
128
|
+
f.unlink()
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def session_start_hook(payload: dict) -> str | None:
|
|
134
|
+
"""Claude Code SessionStart hook: persist the conversation id for later Bash calls.
|
|
135
|
+
Returns the line written, or None when there was nowhere to write it."""
|
|
136
|
+
sid = str(payload.get("session_id") or "").strip()
|
|
137
|
+
target = os.environ.get("CLAUDE_ENV_FILE")
|
|
138
|
+
if not sid or not target:
|
|
139
|
+
return None
|
|
140
|
+
safe = "".join(c for c in sid if c.isalnum() or c in "-_.")[:MAX_LEN - 7]
|
|
141
|
+
line = f"export KW_SESSION=claude:{safe}\n"
|
|
142
|
+
with open(target, "a") as f:
|
|
143
|
+
f.write(line)
|
|
144
|
+
return line.strip()
|
keywright/setup/check.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""`kw setup check`: the doctor. Every line is a fact with a verdict; nonzero exit on any failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from .. import __version__, config, session
|
|
13
|
+
from .claude_code import LEGACY_HOOK_FILE, mcp_registrations, skill_version
|
|
14
|
+
from .codex import BEGIN, END, codex_home
|
|
15
|
+
from .common import binary_path, claude_dir, find_hooks, read_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _line(ok: bool | None, what: str, detail: str = "") -> tuple[bool | None, str]:
|
|
19
|
+
mark = {True: "ok ", False: "FAIL", None: "info"}[ok]
|
|
20
|
+
return ok, f"{mark} {what}" + (f": {detail}" if detail else "")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def integration_checks() -> list[tuple[bool | None, str]]:
|
|
24
|
+
"""Check integrations present on this machine, including Codex-only installs."""
|
|
25
|
+
lines = []
|
|
26
|
+
home = claude_dir()
|
|
27
|
+
kw = binary_path()
|
|
28
|
+
has_claude = home.exists() or shutil.which("claude") is not None
|
|
29
|
+
codex_dir = codex_home()
|
|
30
|
+
has_codex = codex_dir.exists() or shutil.which("codex") is not None
|
|
31
|
+
if has_claude:
|
|
32
|
+
settings = read_json(home / "settings.json")
|
|
33
|
+
hooks = find_hooks(settings)
|
|
34
|
+
if hooks:
|
|
35
|
+
stale = [h for h in hooks if kw not in h and "gatekeeper-hazards.py" in h]
|
|
36
|
+
lines.append(_line(not stale, "claude code hooks", "; ".join(hooks)))
|
|
37
|
+
if stale:
|
|
38
|
+
lines.append(_line(False, "legacy hook", "points at a copied file; run `kw setup claude-code`"))
|
|
39
|
+
else:
|
|
40
|
+
lines.append(_line(False, "claude code hooks", "none; run `kw setup claude-code`"))
|
|
41
|
+
if (home / LEGACY_HOOK_FILE).exists():
|
|
42
|
+
lines.append(_line(False, "legacy hook file", f"{home / LEGACY_HOOK_FILE} still present"))
|
|
43
|
+
sv = skill_version(home / "skills" / "keywright" / "SKILL.md")
|
|
44
|
+
lines.append(_line(sv == __version__, "claude code skill", sv or "not installed"))
|
|
45
|
+
else:
|
|
46
|
+
lines.append(_line(None, "claude code", "not installed; skipped"))
|
|
47
|
+
if has_codex:
|
|
48
|
+
# Match Codex's global instruction-file precedence.
|
|
49
|
+
candidates = [codex_dir / "AGENTS.override.md", codex_dir / "AGENTS.md"]
|
|
50
|
+
active = next((p for p in candidates if p.is_file() and p.read_text().strip()), None)
|
|
51
|
+
text = active.read_text() if active else ""
|
|
52
|
+
installed = BEGIN in text and END in text and text.index(BEGIN) < text.index(END)
|
|
53
|
+
detail = str(active) if installed else "Keywright instructions missing from the active global file; run `kw setup codex`"
|
|
54
|
+
lines.append(_line(installed, "codex instructions", detail))
|
|
55
|
+
if not has_claude and not has_codex:
|
|
56
|
+
lines.append(_line(False, "agent integration", "none; run `kw setup codex` or `kw setup claude-code`"))
|
|
57
|
+
regs = mcp_registrations() if has_claude else []
|
|
58
|
+
lines.append(_line(None, "mcp registrations", ", ".join(regs) if regs else "none (CLI only)"))
|
|
59
|
+
return lines
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(*, profile_name: str | None = None, out=None) -> int:
|
|
63
|
+
out = out or sys.stdout
|
|
64
|
+
lines: list[tuple[bool | None, str]] = []
|
|
65
|
+
lines.append(_line(None, "client", f"keywright {__version__} at {binary_path()}"))
|
|
66
|
+
|
|
67
|
+
profile = None
|
|
68
|
+
try:
|
|
69
|
+
profile = config.load(profile_name)
|
|
70
|
+
lines.append(_line(True, "profile", f"{profile.name} -> {profile.mcp_url}"))
|
|
71
|
+
except config.ConfigError as e:
|
|
72
|
+
lines.append(_line(False, "profile", str(e)))
|
|
73
|
+
mode = config.file_mode_ok()
|
|
74
|
+
if mode is not None:
|
|
75
|
+
lines.append(_line(mode, "config file mode", "0600" if mode else f"{config.config_file()} is readable by others"))
|
|
76
|
+
|
|
77
|
+
if profile:
|
|
78
|
+
base = profile.mcp_url[: -len("/mcp")]
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(base + "/healthz", timeout=10) as r:
|
|
81
|
+
health = json.loads(r.read().decode())
|
|
82
|
+
lines.append(_line(bool(health.get("ok")), "server", f"{base} version {health.get('version', '?')} "
|
|
83
|
+
f"notifier {health.get('notifier', '?')}"))
|
|
84
|
+
sv = str(health.get("version") or "")
|
|
85
|
+
if sv and sv.split(".")[:2] != __version__.split(".")[:2]:
|
|
86
|
+
lines.append(_line(False, "version match", f"server {sv} vs client {__version__}"))
|
|
87
|
+
except (urllib.error.URLError, ValueError, OSError) as e:
|
|
88
|
+
lines.append(_line(False, "server", f"{base}/healthz unreachable ({e})"))
|
|
89
|
+
try:
|
|
90
|
+
from ..transport import AuthError, TransportError, call, payload
|
|
91
|
+
|
|
92
|
+
listing = payload(call(profile, None, "access_list_resources", {}, read_timeout=20))
|
|
93
|
+
lines.append(_line(True, "token", f"agent {listing.get('agent')} sees {len(listing.get('resources', []))} resource(s)"))
|
|
94
|
+
except AuthError:
|
|
95
|
+
lines.append(_line(False, "token", "rejected (401); run `kw login` with a current token"))
|
|
96
|
+
except TransportError as e:
|
|
97
|
+
lines.append(_line(False, "token", f"could not call the server ({e})"))
|
|
98
|
+
|
|
99
|
+
value, source = session.resolve()
|
|
100
|
+
lines.append(_line(None, "session", f"{value or 'none'} ({source})"))
|
|
101
|
+
|
|
102
|
+
lines.extend(integration_checks())
|
|
103
|
+
for tool in ("claude", "codex"):
|
|
104
|
+
path = shutil.which(tool)
|
|
105
|
+
if path:
|
|
106
|
+
try:
|
|
107
|
+
ver = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10, check=False).stdout.strip()
|
|
108
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
109
|
+
ver = "?"
|
|
110
|
+
lines.append(_line(None, tool, ver.splitlines()[0] if ver else path))
|
|
111
|
+
|
|
112
|
+
failed = any(ok is False for ok, _ in lines)
|
|
113
|
+
for _, text in lines:
|
|
114
|
+
out.write(text + "\n")
|
|
115
|
+
return 1 if failed else 0
|