pomaster 0.6.1 → 0.8.0
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.
- package/README.md +231 -10
- package/dist/bin.js +38831 -27372
- package/package.json +1 -1
- package/seeds/hooks/exec-guard-hook.py +586 -0
package/package.json
CHANGED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
exec-guard-hook.py —— PreToolUse 写前拦截(P0-1 Prevention 半边)。
|
|
5
|
+
|
|
6
|
+
任务锚:.trellis/tasks/09-11-self-hosting-exec-guard/prd.md R2-R4。
|
|
7
|
+
本仓自举接线:Claude Code(.claude/settings.json PreToolUse matcher =
|
|
8
|
+
Edit|Write|NotebookEdit|Bash)→ 本脚本 → `pomaster exec-guard`(八拍④机器执行点,
|
|
9
|
+
packages/cli/src/exec-guard.ts——判卷权威单一实现,本脚本零第二判卷逻辑)→ permit。
|
|
10
|
+
|
|
11
|
+
三态语义(PRD R2 逐字;全部本地判定、零网络、零交互):
|
|
12
|
+
- (a) 透传放行(exit 0 + stderr 一行注记):store 未初始化 / 无活跃 execution /
|
|
13
|
+
目标无 KEYBINDING 绑定(unmapped)/ 命令无路径词形。条件激活语义——
|
|
14
|
+
仅在「store 已初始化且存在活跃 execution」时进入判卷,非治理态日常操作不堵。
|
|
15
|
+
- (b) DENY(exit 2 + stderr hint 指路正确 permit 面):活跃 execution 在册 +
|
|
16
|
+
目标经 KEYBINDING 映射到 governed id + exec-guard 判卷非 allow
|
|
17
|
+
(outside_scope / unknown_permit / expired / policy_forbidden),
|
|
18
|
+
或活跃 execution 显式无 permit(permit_ids 空——显式无许可非缺席,
|
|
19
|
+
与 execution audit 空 scope 面同语义:任何映射不可授予 in-scope)。
|
|
20
|
+
- (c) fail-open 放行(exit 0 + stderr 显式声明):判卷器自身故障(CLI 入口缺席 /
|
|
21
|
+
node 缺席 / exec-guard 进程故障 / 判卷系统码 ATTEMPT_MALFORMED·FATAL_*·
|
|
22
|
+
KERNEL_ERROR·NOT_INITIALIZED / store 台账损坏 / 本脚本自身异常)。
|
|
23
|
+
防 hook 把开发流程卡死;放行留下审计线索,由 Detection 半边
|
|
24
|
+
(`pomaster execution audit`,PR #6 ①号切片)兜底复查。
|
|
25
|
+
- 合规写入 ALLOW(exit 0 + stderr 注记):活跃 execution 的任一 permit
|
|
26
|
+
scope.subject_ids 覆盖映射 id(多 execution / 多 permit 取并集——与
|
|
27
|
+
execution audit judgeMutationScope 的「多路径 × 多 permit 并集面」同判据方向)。
|
|
28
|
+
跨目标聚合语义(W1 R1-0):多目标取全称——任一 mapped 目标越界即 DENY;
|
|
29
|
+
DENY stderr 枚举全部越界目标(denied 列表持有全量);unmapped / 无 store /
|
|
30
|
+
无活跃 execution 透传面不参与全称(条件激活语义原样)。
|
|
31
|
+
|
|
32
|
+
边界(PRD Out of Scope,如实标注):
|
|
33
|
+
- Bash matcher 只做路径词形粗筛,不做 shell 语义分析:按空白/引号切词取疑似路径
|
|
34
|
+
token 逐个判卷(op 一律 upsert_object——kernel v0 唯一可放行的写 op,delete 恒
|
|
35
|
+
denied、transition 非文件写语义);读命令若恰好提及 mapped 路径会误报 DENY,
|
|
36
|
+
属粗筛已知代价;写入语义分析(rm/setter 区分)不做。
|
|
37
|
+
- permit 自动签发不做(permit 仍走 Owner / `pomaster permit issue` 既有通路)。
|
|
38
|
+
- 活跃 execution 判定 = .pomaster/executions/*.json 中 ended_at 为 null 的档案
|
|
39
|
+
(跨 session 并集;不按 hook session_id 收窄——session_key 绑定是可选字段,
|
|
40
|
+
缺席诚实优先于猜测)。
|
|
41
|
+
- KEYBINDING 行镜像 execution-audit.ts 的最小词形(id/binding_class/canonical_id/
|
|
42
|
+
physical_path/binding_status);malformed 行跳过并 stderr 披露(fail-open 方向,
|
|
43
|
+
结构化披露由 Detection 半边 KEYBINDING_ROW_MALFORMED 承接)。路径段镜像
|
|
44
|
+
cli/execution-audit.ts KEYBINDINGS_DIR_RELATIVE(.pomaster/truth/keybindings)——
|
|
45
|
+
Python 侧无法 import TS 单源,漂移由两端测试钉(execution-audit.spec + 本 spec)。
|
|
46
|
+
|
|
47
|
+
退出码契约(Claude Code hooks):0 = 放行(stdout/stderr 不阻断);2 = 阻断工具
|
|
48
|
+
调用(stderr 回喂 agent);其余非零 = 非阻断错误。本脚本对一切自身异常兜底为
|
|
49
|
+
fail-open exit 0——判卷器永不因自身故障阻塞开发流程。
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
import time
|
|
59
|
+
|
|
60
|
+
# Windows 控制台缺省码页(cp936 等)会毁掉 stderr 中文注记——与 session-start.py
|
|
61
|
+
# 同款纪律:stderr/stdout 重配置 utf-8(errors=replace 防单字符毁整行)。
|
|
62
|
+
if sys.platform.startswith("win"):
|
|
63
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
64
|
+
if hasattr(_stream, "reconfigure"):
|
|
65
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# 常量(镜像面见头注「边界」段)
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
STDERR_PREFIX = "[pomaster exec-guard hook]"
|
|
72
|
+
|
|
73
|
+
STORE_INDEX_RELATIVE = os.path.join(".pomaster", "state", "truth-index.json")
|
|
74
|
+
EXECUTIONS_DIR_RELATIVE = os.path.join(".pomaster", "executions")
|
|
75
|
+
KEYBINDINGS_DIR_RELATIVE = os.path.join(".pomaster", "truth", "keybindings")
|
|
76
|
+
|
|
77
|
+
# 八拍④判卷输入 op:kernel WriteAttempt 三值闭包中唯一可被 permit 放行的写语义
|
|
78
|
+
# (kernel v0:delete 恒 denied(无 delete 事务通道);transition_object 是状态
|
|
79
|
+
# 迁移语义非文件落笔。Bash 粗筛同用此 op——头注「边界」段)。
|
|
80
|
+
WRITE_OP = "upsert_object"
|
|
81
|
+
|
|
82
|
+
# KEYBINDING binding_class 两类路径锚词形(镜像 @pomaster/schemas 04 schema +
|
|
83
|
+
# execution-audit.ts bindingMatchesPath;contract_operation_to_operationId 的
|
|
84
|
+
# operationId 锚非文件系统路径,结构性不参与文件映射)。
|
|
85
|
+
BINDING_CLASS_FILE_ANCHOR = "capability_to_file"
|
|
86
|
+
BINDING_CLASS_DIR_ANCHOR = "page_to_dir"
|
|
87
|
+
|
|
88
|
+
# governed id 粗文法(SEGMENT.DOT 形态;closed-world 前缀闭包判卷归 exec-guard
|
|
89
|
+
# parseGovernedId——此处只拦截明显非 id 的行,不做权威词法)。
|
|
90
|
+
GOVERNED_ID_COARSE = re.compile(r"^[A-Z][A-Z0-9_]*(\.[A-Z0-9_]+)+$")
|
|
91
|
+
|
|
92
|
+
# exec-guard 判卷结论码:PERMIT_* 五码 + DENOMINATOR_DELETE_FORBIDDEN 来自
|
|
93
|
+
# kernel checkPermit 四态三因(deniedReasonToCode / PERMIT_EXPIRED /
|
|
94
|
+
# PERMIT_UNKNOWN)——是真判卷结论,不是判卷器故障。
|
|
95
|
+
DENIAL_CODE_PREFIX = "PERMIT_"
|
|
96
|
+
DENIAL_CODE_EXTRA = {"DENOMINATOR_DELETE_FORBIDDEN"}
|
|
97
|
+
|
|
98
|
+
# 单次 exec-guard 子进程超时上界 / 全 hook 预算上界(超时上界 = PRD R2 约束)。
|
|
99
|
+
JUDGE_SPAWN_TIMEOUT_SECONDS = 20.0
|
|
100
|
+
DEFAULT_DEADLINE_SECONDS = 50.0
|
|
101
|
+
DEADLINE_ENV = "POMASTER_HOOK_DEADLINE_SECONDS"
|
|
102
|
+
# CLI 入口注入面(默认 = 自举布局 <store_root>/packages/cli/dist/bin.js)。
|
|
103
|
+
CLI_ENTRY_ENV = "POMASTER_CLI_ENTRY"
|
|
104
|
+
|
|
105
|
+
TOOL_PATH_KEYS = ("file_path", "notebook_path")
|
|
106
|
+
BASH_TOOL = "Bash"
|
|
107
|
+
SUPPORTED_TOOLS = {"Edit", "Write", "NotebookEdit", BASH_TOOL}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# stderr 注记(一行一注记;审计线索归 Detection 半边兜底复查)
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def note(message: str) -> None:
|
|
116
|
+
print(f"{STDERR_PREFIX} {message}", file=sys.stderr)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fail_open(reason: str) -> int:
|
|
120
|
+
note(
|
|
121
|
+
f"FAIL-OPEN: {reason}——本次放行不是治理判定;审计兜底 = "
|
|
122
|
+
f"`pomaster execution audit --execution-id <AGX-n> --diff-base <ref>`(Detection 半边)。"
|
|
123
|
+
)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def pass_through(reason: str) -> int:
|
|
128
|
+
note(f"pass-through: {reason}(条件激活语义,未进入判卷)")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# 路径解析(跨平台:os.path 为准;比较一律 normcase——Windows 盘面大小写不敏感)
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def find_store_root(start_dir: str) -> str | None:
|
|
138
|
+
"""自 start_dir 逐级向上找 .pomaster/state/truth-index.json(最近优先)。"""
|
|
139
|
+
current = os.path.abspath(start_dir)
|
|
140
|
+
while True:
|
|
141
|
+
if os.path.isfile(os.path.join(current, *STORE_INDEX_RELATIVE.split(os.sep))):
|
|
142
|
+
return current
|
|
143
|
+
parent = os.path.dirname(current)
|
|
144
|
+
if parent == current:
|
|
145
|
+
return None
|
|
146
|
+
current = parent
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def resolve_target(raw: str, cwd: str) -> str | None:
|
|
150
|
+
"""工具入参路径词形 → 绝对路径(相对词形锚 payload cwd)。"""
|
|
151
|
+
text = raw.strip()
|
|
152
|
+
if not text:
|
|
153
|
+
return None
|
|
154
|
+
if not os.path.isabs(text) and not re.match(r"^[A-Za-z]:[\\/]", text):
|
|
155
|
+
text = os.path.join(cwd, text)
|
|
156
|
+
return os.path.abspath(text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def rel_posix(store_root: str, abs_path: str) -> str | None:
|
|
160
|
+
"""绝对路径 → store 根相对 posix 词形;不在 store 树内 / 跨盘 → None。"""
|
|
161
|
+
try:
|
|
162
|
+
rel = os.path.relpath(abs_path, store_root)
|
|
163
|
+
except ValueError:
|
|
164
|
+
return None
|
|
165
|
+
if rel.startswith("..") or os.path.isabs(rel):
|
|
166
|
+
return None
|
|
167
|
+
return rel.replace(os.sep, "/")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def bash_path_candidates(command: str) -> list[str]:
|
|
171
|
+
"""Bash 命令路径词形粗筛(头注「边界」:只切词不做 shell 语义分析)。"""
|
|
172
|
+
normalized = re.sub(r"[\"'`(){}\[\];|<>&]", " ", command)
|
|
173
|
+
candidates: list[str] = []
|
|
174
|
+
for token in normalized.split():
|
|
175
|
+
token = token.strip(".,:!?")
|
|
176
|
+
if not token or token.startswith("-"):
|
|
177
|
+
continue
|
|
178
|
+
if "://" in token:
|
|
179
|
+
continue
|
|
180
|
+
if "/" in token or "\\" in token or re.search(r"\.[A-Za-z0-9_]+$", token):
|
|
181
|
+
candidates.append(token)
|
|
182
|
+
return candidates
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# store 面:初始化 / 活跃 execution / KEYBINDING 绑定表
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_active_executions(store_root: str) -> list[dict] | None:
|
|
191
|
+
"""返回 ended_at 为 null 的执行档案列表;台账损坏 → None(判卷器故障信号)。"""
|
|
192
|
+
exec_dir = os.path.join(store_root, *EXECUTIONS_DIR_RELATIVE.split(os.sep))
|
|
193
|
+
try:
|
|
194
|
+
names = sorted(name for name in os.listdir(exec_dir) if name.endswith(".json"))
|
|
195
|
+
except OSError:
|
|
196
|
+
return [] # 目录缺席 = 零档案(无活跃 execution 的合法态)
|
|
197
|
+
active: list[dict] = []
|
|
198
|
+
for name in names:
|
|
199
|
+
path = os.path.join(exec_dir, name)
|
|
200
|
+
try:
|
|
201
|
+
with open(path, encoding="utf-8") as handle:
|
|
202
|
+
record = json.load(handle)
|
|
203
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
204
|
+
note(f"执行档案不可解析(损坏或手改):.pomaster/executions/{name}({exc})")
|
|
205
|
+
return None
|
|
206
|
+
if not isinstance(record, dict) or not isinstance(record.get("execution_id"), str):
|
|
207
|
+
note(f"执行档案形态非法(execution_id 缺席):.pomaster/executions/{name}")
|
|
208
|
+
return None
|
|
209
|
+
if record.get("ended_at") is None:
|
|
210
|
+
active.append(record)
|
|
211
|
+
return active
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_keybinding_rows(store_root: str) -> list[dict]:
|
|
215
|
+
"""装载 KEYBINDING 绑定行(最小词形;malformed 行跳过 + stderr 披露)。"""
|
|
216
|
+
kb_dir = os.path.join(store_root, *KEYBINDINGS_DIR_RELATIVE.split(os.sep))
|
|
217
|
+
try:
|
|
218
|
+
names = sorted(name for name in os.listdir(kb_dir) if name.endswith(".json"))
|
|
219
|
+
except OSError:
|
|
220
|
+
return [] # 目录缺席 = 无绑定表(全 unmapped 合法态,与 Detection 半边同语义)
|
|
221
|
+
rows: list[dict] = []
|
|
222
|
+
for name in names:
|
|
223
|
+
path = os.path.join(kb_dir, name)
|
|
224
|
+
try:
|
|
225
|
+
with open(path, encoding="utf-8") as handle:
|
|
226
|
+
parsed = json.load(handle)
|
|
227
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
228
|
+
note(f"KEYBINDING 行不可解析(跳过该行,fail-open 方向):{name}({exc})")
|
|
229
|
+
continue
|
|
230
|
+
if not isinstance(parsed, dict):
|
|
231
|
+
note(f"KEYBINDING 行非对象(跳过该行):{name}")
|
|
232
|
+
continue
|
|
233
|
+
row_id = parsed.get("id")
|
|
234
|
+
binding_class = parsed.get("binding_class")
|
|
235
|
+
canonical_id = parsed.get("canonical_id")
|
|
236
|
+
physical_path = parsed.get("physical_path")
|
|
237
|
+
if (
|
|
238
|
+
not isinstance(row_id, str)
|
|
239
|
+
or not isinstance(binding_class, str)
|
|
240
|
+
or not isinstance(canonical_id, str)
|
|
241
|
+
or not isinstance(physical_path, str)
|
|
242
|
+
or binding_class not in (BINDING_CLASS_FILE_ANCHOR, BINDING_CLASS_DIR_ANCHOR)
|
|
243
|
+
or not GOVERNED_ID_COARSE.match(canonical_id)
|
|
244
|
+
or not physical_path
|
|
245
|
+
):
|
|
246
|
+
note(f"KEYBINDING 行词形不参与文件映射(跳过该行):{name}")
|
|
247
|
+
continue
|
|
248
|
+
rows.append(
|
|
249
|
+
{
|
|
250
|
+
"file": name,
|
|
251
|
+
"binding_class": binding_class,
|
|
252
|
+
"canonical_id": canonical_id,
|
|
253
|
+
# 物理锚统一 posix 词形再 normcase(Windows 盘面大小写不敏感)。
|
|
254
|
+
"anchor": os.path.normcase(physical_path.replace("\\", "/")),
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
return rows
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def map_path_to_ids(rel: str, rows: list[dict]) -> list[str]:
|
|
261
|
+
"""store 相对 posix 路径 → 映射 governed id 去重清单(execution-audit 同锚语义)。"""
|
|
262
|
+
rel_norm = os.path.normcase(rel)
|
|
263
|
+
ids: list[str] = []
|
|
264
|
+
for row in rows:
|
|
265
|
+
anchor = row["anchor"]
|
|
266
|
+
if row["binding_class"] == BINDING_CLASS_FILE_ANCHOR:
|
|
267
|
+
matched = rel_norm == anchor
|
|
268
|
+
else: # page_to_dir:路径等于锚或位于锚目录之下
|
|
269
|
+
matched = rel_norm == anchor or rel_norm.startswith(anchor + "/")
|
|
270
|
+
if matched and row["canonical_id"] not in ids:
|
|
271
|
+
ids.append(row["canonical_id"])
|
|
272
|
+
return ids
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# exec-guard 判卷(唯一判卷权威 = CLI exec-guard;本文件零第二判卷逻辑)
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def resolve_cli_entry(store_root: str) -> str | None:
|
|
281
|
+
entry = os.environ.get(CLI_ENTRY_ENV, "").strip()
|
|
282
|
+
if entry:
|
|
283
|
+
return entry if os.path.isfile(entry) else None
|
|
284
|
+
default = os.path.join(store_root, "packages", "cli", "dist", "bin.js")
|
|
285
|
+
return default if os.path.isfile(default) else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def judge_once(
|
|
289
|
+
store_root: str, permit_ref: str, governed_id: str, raw_target: str, tool: str
|
|
290
|
+
) -> dict:
|
|
291
|
+
"""单发 exec-guard 判卷。返回 {kind: allowed|denied|fault, ...}。"""
|
|
292
|
+
cli_entry = resolve_cli_entry(store_root)
|
|
293
|
+
if cli_entry is None:
|
|
294
|
+
return {
|
|
295
|
+
"kind": "fault",
|
|
296
|
+
"reason": f"exec-guard CLI 入口缺席({CLI_ENTRY_ENV} 缺省解析 "
|
|
297
|
+
f"<store_root>/packages/cli/dist/bin.js 均不可用)",
|
|
298
|
+
}
|
|
299
|
+
node = os.environ.get("POMASTER_NODE", "node")
|
|
300
|
+
attempt = {
|
|
301
|
+
"permit_ref": permit_ref,
|
|
302
|
+
"id": governed_id,
|
|
303
|
+
"op": WRITE_OP,
|
|
304
|
+
"context": {"surface": "pretooluse-hook", "tool": tool, "target": raw_target},
|
|
305
|
+
}
|
|
306
|
+
cmd = [node, cli_entry, "--dir", store_root, "exec-guard", "--attempt", "-", "--json"]
|
|
307
|
+
try:
|
|
308
|
+
proc = subprocess.run(
|
|
309
|
+
cmd,
|
|
310
|
+
input=json.dumps(attempt).encode("utf-8"),
|
|
311
|
+
stdout=subprocess.PIPE,
|
|
312
|
+
stderr=subprocess.PIPE,
|
|
313
|
+
timeout=JUDGE_SPAWN_TIMEOUT_SECONDS,
|
|
314
|
+
)
|
|
315
|
+
except subprocess.TimeoutExpired:
|
|
316
|
+
return {"kind": "fault", "reason": f"exec-guard 子进程超时(>{JUDGE_SPAWN_TIMEOUT_SECONDS:.0f}s)"}
|
|
317
|
+
except OSError as exc:
|
|
318
|
+
return {"kind": "fault", "reason": f"exec-guard 子进程无法启动(node/CLI 入口故障):{exc}"}
|
|
319
|
+
|
|
320
|
+
envelope = None
|
|
321
|
+
try:
|
|
322
|
+
envelope = json.loads(proc.stdout.decode("utf-8", errors="replace").strip())
|
|
323
|
+
except json.JSONDecodeError:
|
|
324
|
+
envelope = None
|
|
325
|
+
code = None
|
|
326
|
+
message = None
|
|
327
|
+
hint = None
|
|
328
|
+
if isinstance(envelope, dict) and isinstance(envelope.get("errors"), list) and envelope["errors"]:
|
|
329
|
+
first = envelope["errors"][0]
|
|
330
|
+
if isinstance(first, dict):
|
|
331
|
+
code = first.get("code") if isinstance(first.get("code"), str) else None
|
|
332
|
+
message = first.get("message") if isinstance(first.get("message"), str) else None
|
|
333
|
+
hint = first.get("hint") if isinstance(first.get("hint"), str) else None
|
|
334
|
+
if proc.returncode == 0:
|
|
335
|
+
return {"kind": "allowed", "permit_ref": permit_ref, "id": governed_id}
|
|
336
|
+
if proc.returncode == 1 and code is not None and (
|
|
337
|
+
code.startswith(DENIAL_CODE_PREFIX) or code in DENIAL_CODE_EXTRA
|
|
338
|
+
):
|
|
339
|
+
return {
|
|
340
|
+
"kind": "denied",
|
|
341
|
+
"permit_ref": permit_ref,
|
|
342
|
+
"id": governed_id,
|
|
343
|
+
"code": code,
|
|
344
|
+
"message": message or "",
|
|
345
|
+
"hint": hint or "",
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
"kind": "fault",
|
|
349
|
+
"reason": f"exec-guard 进程异常(exit={proc.returncode},code={code},"
|
|
350
|
+
f"stderr={proc.stderr.decode('utf-8', errors='replace').strip()[:160]})",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# 主通路
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def hook_targets(payload: dict) -> tuple[list[str], str | None]:
|
|
360
|
+
"""工具入参 → (候选目标原始词形, Bash 命令)。非 Bash = file/notebook_path。"""
|
|
361
|
+
tool_input = payload.get("tool_input")
|
|
362
|
+
if not isinstance(tool_input, dict):
|
|
363
|
+
return [], None
|
|
364
|
+
tool = payload.get("tool_name")
|
|
365
|
+
if tool == BASH_TOOL:
|
|
366
|
+
command = tool_input.get("command")
|
|
367
|
+
return (
|
|
368
|
+
bash_path_candidates(command) if isinstance(command, str) else [],
|
|
369
|
+
command if isinstance(command, str) else None,
|
|
370
|
+
)
|
|
371
|
+
for key in TOOL_PATH_KEYS:
|
|
372
|
+
value = tool_input.get(key)
|
|
373
|
+
if isinstance(value, str) and value.strip():
|
|
374
|
+
return [value], None
|
|
375
|
+
return [], None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main() -> int:
|
|
379
|
+
started = time.monotonic()
|
|
380
|
+
deadline = DEFAULT_DEADLINE_SECONDS
|
|
381
|
+
raw_deadline = os.environ.get(DEADLINE_ENV, "").strip()
|
|
382
|
+
if raw_deadline:
|
|
383
|
+
try:
|
|
384
|
+
deadline = max(1.0, float(raw_deadline))
|
|
385
|
+
except ValueError:
|
|
386
|
+
deadline = DEFAULT_DEADLINE_SECONDS
|
|
387
|
+
|
|
388
|
+
payload: dict = {}
|
|
389
|
+
try:
|
|
390
|
+
parsed = json.loads(sys.stdin.read() or "{}")
|
|
391
|
+
if isinstance(parsed, dict):
|
|
392
|
+
payload = parsed
|
|
393
|
+
except json.JSONDecodeError:
|
|
394
|
+
return fail_open("hook 入参 stdin 非 JSON(harness 形态漂移?)——无法定位判卷目标")
|
|
395
|
+
|
|
396
|
+
tool = payload.get("tool_name")
|
|
397
|
+
if tool not in SUPPORTED_TOOLS:
|
|
398
|
+
# 非 matcher 工具(防御面):matcher 与 SUPPORTED_TOOLS 漂移时仍留注记,
|
|
399
|
+
# 禁静默放行(透传态必须有 stderr 注记——审计线索纪律)。
|
|
400
|
+
note(
|
|
401
|
+
f"pass-through: tool={tool} 非 matcher 契约面(settings.json matcher 与 "
|
|
402
|
+
f"SUPPORTED_TOOLS 漂移防御)——未判卷放行"
|
|
403
|
+
)
|
|
404
|
+
return 0
|
|
405
|
+
cwd = payload.get("cwd")
|
|
406
|
+
cwd = cwd if isinstance(cwd, str) and cwd.strip() else os.getcwd()
|
|
407
|
+
|
|
408
|
+
raw_targets, bash_command = hook_targets(payload)
|
|
409
|
+
if not raw_targets:
|
|
410
|
+
if tool == BASH_TOOL:
|
|
411
|
+
return pass_through("Bash 命令无路径词形(粗筛零候选)")
|
|
412
|
+
return pass_through("工具入参无路径目标")
|
|
413
|
+
|
|
414
|
+
# —— 按 store 根分桶(walk-up 最近优先;无 store 的目标 = 透传面) ——
|
|
415
|
+
buckets: dict[str, list[str]] = {}
|
|
416
|
+
unrooted: list[str] = []
|
|
417
|
+
for raw in raw_targets:
|
|
418
|
+
absolute = resolve_target(raw, cwd)
|
|
419
|
+
if absolute is None:
|
|
420
|
+
continue
|
|
421
|
+
root = find_store_root(os.path.dirname(absolute) or cwd)
|
|
422
|
+
if root is None:
|
|
423
|
+
unrooted.append(raw)
|
|
424
|
+
continue
|
|
425
|
+
buckets.setdefault(root, []).append(absolute)
|
|
426
|
+
if unrooted:
|
|
427
|
+
note(f"pass-through: {len(unrooted)} 个目标不在任何 .pomaster store 树内")
|
|
428
|
+
|
|
429
|
+
verdicts: list[dict] = []
|
|
430
|
+
judged_targets = 0
|
|
431
|
+
budget_exceeded = False
|
|
432
|
+
for store_root, targets in buckets.items():
|
|
433
|
+
active = load_active_executions(store_root)
|
|
434
|
+
if active is None:
|
|
435
|
+
return fail_open(f"执行档案台账损坏({store_root})——活跃性不可判定")
|
|
436
|
+
if not active:
|
|
437
|
+
note(
|
|
438
|
+
"pass-through: 无活跃 execution(executions/ 档案全封口或缺席)"
|
|
439
|
+
f"——store={store_root}"
|
|
440
|
+
)
|
|
441
|
+
continue
|
|
442
|
+
rows = load_keybinding_rows(store_root)
|
|
443
|
+
budget_exceeded = False
|
|
444
|
+
for raw in targets:
|
|
445
|
+
absolute = resolve_target(raw, cwd)
|
|
446
|
+
if absolute is None:
|
|
447
|
+
continue
|
|
448
|
+
rel = rel_posix(store_root, absolute)
|
|
449
|
+
if rel is None:
|
|
450
|
+
note(f"pass-through: 目标不在 store 树内:{raw}")
|
|
451
|
+
continue
|
|
452
|
+
mapped_ids = map_path_to_ids(rel, rows)
|
|
453
|
+
if not mapped_ids:
|
|
454
|
+
note(
|
|
455
|
+
f"pass-through: 目标无 KEYBINDING 绑定(unmapped,非违规——审计线索归 "
|
|
456
|
+
f"execution audit):{rel}"
|
|
457
|
+
)
|
|
458
|
+
continue
|
|
459
|
+
judged_targets += 1
|
|
460
|
+
permit_refs: list[str] = []
|
|
461
|
+
permitless = False
|
|
462
|
+
for record in active:
|
|
463
|
+
ids = record.get("permit_ids")
|
|
464
|
+
exec_id = record.get("execution_id", "?")
|
|
465
|
+
if not isinstance(ids, list):
|
|
466
|
+
permitless = True
|
|
467
|
+
note(f"执行档案 permit_ids 非 list(按显式无 permit 判卷):{exec_id}")
|
|
468
|
+
continue
|
|
469
|
+
usable = [ref for ref in ids if isinstance(ref, str) and ref]
|
|
470
|
+
if len(usable) != len(ids):
|
|
471
|
+
note(
|
|
472
|
+
f"执行档案 permit_ids 含 {len(ids) - len(usable)} 个非字符串/空条目"
|
|
473
|
+
f"(不可用条目不构成授权):{exec_id}"
|
|
474
|
+
)
|
|
475
|
+
if not usable:
|
|
476
|
+
permitless = True
|
|
477
|
+
note(f"活跃 execution 显式无 permit(permit_ids 空或全不可用):{exec_id}")
|
|
478
|
+
continue
|
|
479
|
+
for permit_ref in usable:
|
|
480
|
+
if permit_ref not in permit_refs:
|
|
481
|
+
permit_refs.append(permit_ref)
|
|
482
|
+
if permitless and not permit_refs:
|
|
483
|
+
verdicts.append(
|
|
484
|
+
{
|
|
485
|
+
"kind": "denied",
|
|
486
|
+
"permit_ref": None,
|
|
487
|
+
"id": ", ".join(mapped_ids),
|
|
488
|
+
"code": "EXECUTION_WITHOUT_PERMIT",
|
|
489
|
+
"message": f"活跃 execution 无在册 permit(permit_ids 空)——显式无许可非缺席:{raw}",
|
|
490
|
+
"hint": "先 `pomaster permit issue --subject <governed-id> --actor ...` 圈定范围,"
|
|
491
|
+
"再 `pomaster execution begin --permit-id <PERMIT.*>` 重开执行。",
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
continue
|
|
495
|
+
# 循环不变量:每条 active 档案要么贡献 usable permit_ref,要么置
|
|
496
|
+
# permitless——此处 permit_refs 空必然 permitless,由下方 DENY 支承接。
|
|
497
|
+
for governed_id in mapped_ids:
|
|
498
|
+
for permit_ref in permit_refs:
|
|
499
|
+
if time.monotonic() - started > deadline:
|
|
500
|
+
budget_exceeded = True
|
|
501
|
+
break
|
|
502
|
+
verdicts.append(
|
|
503
|
+
judge_once(store_root, permit_ref, governed_id, raw, str(tool))
|
|
504
|
+
)
|
|
505
|
+
if budget_exceeded:
|
|
506
|
+
break
|
|
507
|
+
if budget_exceeded:
|
|
508
|
+
break
|
|
509
|
+
if budget_exceeded:
|
|
510
|
+
note(f"判卷预算耗尽(>{deadline:.0f}s)——剩余目标未逐发判卷")
|
|
511
|
+
break
|
|
512
|
+
|
|
513
|
+
allowed = [verdict for verdict in verdicts if verdict["kind"] == "allowed"]
|
|
514
|
+
denied = [verdict for verdict in verdicts if verdict["kind"] == "denied"]
|
|
515
|
+
faults = [verdict for verdict in verdicts if verdict["kind"] == "fault"]
|
|
516
|
+
|
|
517
|
+
# 目标内 permit 并集归并(W0 裁定 exec-guard-mixed-target.md §3a 精确形态的
|
|
518
|
+
# 前置半):按 governed id 归并判卷结论——∃ permit 覆盖即该目标 allowed,
|
|
519
|
+
# raw verdict 层的个别 denied 不定罪(钉 allow.multi_permit_union 语义:
|
|
520
|
+
# 全称只作用于目标层,不作用于 raw verdict 层)。
|
|
521
|
+
covered_ids = {verdict["id"] for verdict in allowed}
|
|
522
|
+
# 跨目标聚合 = 全称(后置半):任一未被任何 permit 覆盖的越界目标 → 整体
|
|
523
|
+
# DENY——同批混合允许/越界命令不得因任一 allowed verdict 提前放行(与
|
|
524
|
+
# execution audit judgeMutationScope 逐路径严格判据对称,消除「judge_once
|
|
525
|
+
# 已返回 denied 又被汇总层丢弃」的语义倒挂)。unmapped / 无 store / 无活跃
|
|
526
|
+
# execution 透传面不产 verdict,天然不参与全称;DENY 优先于 ALLOW 也使混合
|
|
527
|
+
# 批中预算耗尽的部分判卷落 fail-closed 方向。
|
|
528
|
+
out_of_scope = [verdict for verdict in denied if verdict["id"] not in covered_ids]
|
|
529
|
+
|
|
530
|
+
if out_of_scope:
|
|
531
|
+
first = out_of_scope[0]
|
|
532
|
+
print(f"{STDERR_PREFIX} DENY: {first['message']}", file=sys.stderr)
|
|
533
|
+
if first.get("code"):
|
|
534
|
+
print(f" code: {first['code']}", file=sys.stderr)
|
|
535
|
+
if first.get("hint"):
|
|
536
|
+
print(f" kernel hint: {first['hint']}", file=sys.stderr)
|
|
537
|
+
# 枚举全部越界结论(denied 列表本就持有全量,非仅 denied[0];同 id 多
|
|
538
|
+
# permit 各自失败的 verdict 逐条呈现——∃permit 覆盖即 allowed 的并集语义
|
|
539
|
+
# 下,在册即该目标对全部 permit 失败的实证)。单 verdict 时 message 已含
|
|
540
|
+
# 目标 id,枚举块免重复。
|
|
541
|
+
if len(out_of_scope) > 1:
|
|
542
|
+
print(
|
|
543
|
+
f" 越界目标枚举({len(out_of_scope)} 发判卷结论全部越界,跨目标全称聚合):",
|
|
544
|
+
file=sys.stderr,
|
|
545
|
+
)
|
|
546
|
+
for verdict in out_of_scope:
|
|
547
|
+
print(
|
|
548
|
+
f" - id={verdict['id']} code={verdict.get('code') or '-'} "
|
|
549
|
+
f"permit_ref={verdict.get('permit_ref') or '-'}",
|
|
550
|
+
file=sys.stderr,
|
|
551
|
+
)
|
|
552
|
+
print(
|
|
553
|
+
" 正确 permit 面:把目标对象纳入范围须回 FRAMEWORK LOCK 重审(D20,不得旁路扩权)——"
|
|
554
|
+
"`pomaster permit issue --subject <governed-id> --actor human:owner` 圈定范围后 "
|
|
555
|
+
"`pomaster execution begin --permit-id <PERMIT.*>` 重开执行;"
|
|
556
|
+
"台账对账:`pomaster permit list --json`。",
|
|
557
|
+
file=sys.stderr,
|
|
558
|
+
)
|
|
559
|
+
print(
|
|
560
|
+
" 事后审计:`pomaster execution audit --execution-id <AGX-n> --diff-base <ref>`。",
|
|
561
|
+
file=sys.stderr,
|
|
562
|
+
)
|
|
563
|
+
return 2
|
|
564
|
+
if allowed:
|
|
565
|
+
hit = allowed[0]
|
|
566
|
+
note(
|
|
567
|
+
f"ALLOW: {hit['id']} op={WRITE_OP} ∈ {hit['permit_ref']}"
|
|
568
|
+
f"(exec-guard allowed;judged={judged_targets} 目标 / {len(verdicts)} 发判卷)"
|
|
569
|
+
)
|
|
570
|
+
return 0
|
|
571
|
+
if faults:
|
|
572
|
+
return fail_open(faults[0]["reason"])
|
|
573
|
+
if budget_exceeded:
|
|
574
|
+
return fail_open("判卷预算耗尽且未取得任何判卷结论")
|
|
575
|
+
return pass_through("全部目标均为透传面(无 store 树内 mapped 目标)")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
if __name__ == "__main__":
|
|
579
|
+
try:
|
|
580
|
+
sys.exit(main())
|
|
581
|
+
except Exception as exc: # noqa: BLE001 —— 自身异常兜底 fail-open(头注三态 (c))。
|
|
582
|
+
try:
|
|
583
|
+
note(f"FAIL-OPEN: hook 自身异常({type(exc).__name__}: {exc})")
|
|
584
|
+
except Exception:
|
|
585
|
+
pass
|
|
586
|
+
sys.exit(0)
|