@sema-agent/server 7.43.0 → 7.44.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/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,45 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hooks hook-runner — hooks 支持的 service 半场,阶段二。
|
|
3
|
-
*
|
|
4
|
-
* 契约 = `@sema-agent/registry-core/hooks`(0.1.51,30 事件全集、CC 2.1.198 逐字形状);runner 把
|
|
5
|
-
* `TaskRequest.settings.hooks` 里的 **command 类型** 条目翻成 core `TaskSpec.hooks`(types §hooks,
|
|
6
|
-
* 9 个 engine-owned 回调 core 1.254 已收满)的进程内回调。
|
|
7
|
-
*
|
|
8
|
-
* 阶段二范围(engine-owned 9 事件全点亮;加事件=纯实现不动契约):
|
|
9
|
-
* - 事件:PreToolUse/PostToolUse(阶段一,语义不动)+ UserPromptSubmit/Stop/StopFailure/
|
|
10
|
-
* PostToolUseFailure/PostToolBatch/PreCompact/PostCompact(阶段二)。matcher 按契约
|
|
11
|
-
* HOOK_EVENT_MATCHER_FIELD:null 事件(UserPromptSubmit/Stop/PostToolBatch)matcher 被忽略、
|
|
12
|
-
* 全条目都跑;StopFailure 对 error(AssistantTurnError 枚举)、PreCompact/PostCompact 对 trigger、
|
|
13
|
-
* PostToolUseFailure 对 tool_name 匹配。core 回调面是 observe-only 的事件(StopFailure/PostCompact)
|
|
14
|
-
* 的决策类输出记 `hook_result_unsupported`,warn 不吞。
|
|
15
|
-
* - shell-owned 事件(其余 21 个)的配置到达引擎=正常(壳自己跑,owner-single):记一条
|
|
16
|
-
* `hook_events_shell_owned` debug 可见,不告警;engine-owned 且未点亮的事件(阶段二后=空集,
|
|
17
|
-
* 机制保留防契约扩容)才记 `hook_events_not_fired` warn,绝不静默。
|
|
18
|
-
* - 条目类型:`command`(bash 族)+ `http`(阶段三a,见 {@link runHttpHook}——POST 输入 JSON、
|
|
19
|
-
* 2xx JSON body 走同一 SyncHookOutput 决策面)。`prompt`/`agent`(需模型调用设计:默认模型/用量
|
|
20
|
-
* 归属/catalog 门=阶段三b 挂账)与 `shell:"powershell"` 跳过(`hook_entries_skipped` 记账);
|
|
21
|
-
* `async` 支持为 fire-and-forget;`asyncRewake` = async + exit 2 时经 ctx.wake 唤醒模型(live
|
|
22
|
-
* TaskStream steer 注入;无活流/无管道则记账降级);`if` 条件=CC-exact permission-rule 模式
|
|
23
|
-
* (见 {@link hookIfMatches}:不匹配/评不出→跳过该条目记账,对照 CC 2.1.187/198 实测行为核验);
|
|
24
|
-
* `statusMessage` 是壳 UI 字段、服务端无面。
|
|
25
|
-
*
|
|
26
|
-
* 🔒 安全边界(design/107 class ② 能力授予):hook 命令跑在 **worker host**(server 进程环境),不在
|
|
27
|
-
* 沙箱——与 CC 语义一致(CC hooks 跑在用户机器上,哪怕工具在远端执行)。因此挂载点(main.ts)用
|
|
28
|
-
* **单用户闸** `requirePrincipal !== true`,与 MCP 注入/shellEnv/scheduler 同一姿势;多租户 lane
|
|
29
|
-
* 收到 hooks 配置只警告忽略(`task_hooks_ignored`),绝不执行。
|
|
30
|
-
*
|
|
31
|
-
* 🔒 整槽覆盖陷阱(core runtask `spec.hooks ?? deps.hooks`):task 级 hooks 一旦挂上会 shadow 部署级
|
|
32
|
-
* hooks(TOOL_TRACE 观测就挂在 deps.hooks)。{@link composeHooks} 把部署基线折进 task hooks——部署槽
|
|
33
|
-
* 先跑(观测在前,看到真实执行),用户槽后跑,结果按槽语义合并。
|
|
34
|
-
*
|
|
35
|
-
* CC exit-code 语义(与 CC 对齐):
|
|
36
|
-
* - exit 0:stdout 若是 JSON → SyncHookOutput(decision/hookSpecificOutput/continue);非 JSON → 忽略。
|
|
37
|
-
* - exit 2:阻断——PreToolUse → deny;UserPromptSubmit → block 提交;Stop → block 停止(继续干活);
|
|
38
|
-
* PreCompact → block 本次压缩(stderr 均作为模型/追溯可读原因);PostToolUse/PostToolUseFailure/
|
|
39
|
-
* PostToolBatch → 调用已落地无从阻断,stderr 作为 additionalContext 反馈给模型;observe-only 事件
|
|
40
|
-
* (StopFailure/PostCompact)无阻断面,按 hook 故障记账。
|
|
41
|
-
* - 其他 exit / 超时:非阻断,警告记录(hook 故障绝不误伤任务)。
|
|
42
|
-
*/
|
|
43
1
|
import { spawn } from "node:child_process";
|
|
44
2
|
import { hostShell, resolveHostShell } from "../plugins/host-platform.js";
|
|
45
3
|
import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/registry-core/hooks";
|
|
@@ -47,62 +5,23 @@ import { redactSecrets } from "../trace/redact.js";
|
|
|
47
5
|
import { ccPromptSystemFor, wrapCondition, parseCcVerdict, CC_EVALUATOR_MAX_OUTPUT_TOKENS } from "./cc-stop-prompt.js";
|
|
48
6
|
import { ccAgentHookSystemFor } from "./cc-agent-hook-prompt.js";
|
|
49
7
|
import { renderBranchTranscript } from "./branch-transcript.js";
|
|
50
|
-
/** 服务侧防线上限(契约本身不设量纲;不设界=单请求可塞任意大配置/任意长命令)。超界=校验错(fail-loud)。 */
|
|
51
|
-
// S1([1870]):hook 的 shell 与 host lane 同源(core getShellConfig,bash 优先)。模块装载即预热
|
|
52
|
-
// 解析(fire-and-forget;未解析先到的 spawn 拿 sh 兜底=修前字节形,解析后恒 bash)。
|
|
53
8
|
void resolveHostShell().catch(() => undefined);
|
|
54
9
|
export const MAX_HOOK_ENTRIES_PER_EVENT = 32;
|
|
55
10
|
export const MAX_HOOK_COMMAND_CHARS = 8_192;
|
|
56
|
-
/** 单条 hook 超时上限(秒;条目可自设 timeout,但被此值夹住——一个 86400 的 timeout 会挂死工具门)。 */
|
|
57
11
|
export const MAX_HOOK_TIMEOUT_SECONDS = 600;
|
|
58
|
-
/** 一次 hook 事件(一次 pre/post 调用)所有同步条目的墙钟总预算(秒;32 条 × 单条 600s
|
|
59
|
-
* 最坏可串行卡住工具门 ~320min。到点后不再起后续条目,记账降级)。 */
|
|
60
12
|
export const MAX_HOOK_EVENT_TOTAL_SECONDS = 120;
|
|
61
|
-
/** 一次事件的 matcher 组数上限(配置广度的另一维,防组数爆炸)。 */
|
|
62
13
|
export const MAX_HOOK_MATCHER_GROUPS = 16;
|
|
63
|
-
/** http 条目的 header 条数 / allowedEnvVars 个数上限(构造放大面)。 */
|
|
64
14
|
export const MAX_HOOK_HTTP_HEADERS = 32;
|
|
65
|
-
/** hook 进程 stdout/stderr 各自的采集上限(字节)——防输出洪泛打爆内存/日志。 */
|
|
66
15
|
export const MAX_HOOK_OUTPUT_BYTES = 1024 * 1024;
|
|
67
|
-
/** 喂给模型的 deny 原因/additionalContext 的单条长度上限(字符)。 */
|
|
68
16
|
const MAX_HOOK_FEEDBACK_CHARS = 4_096;
|
|
69
|
-
/**
|
|
70
|
-
* 一次事件里所有 hook 上下文片段**聚合后**喂给模型的总量上限(字符)。
|
|
71
|
-
*
|
|
72
|
-
* 为什么单条帽不够:一次事件最多 {@link MAX_HOOK_ENTRIES_PER_EVENT} 条(parseHooksConfig 的广度闸),
|
|
73
|
-
* 每条最多推两段(`decision:"block"` 的 reason + `hookSpecificOutput.additionalContext`),
|
|
74
|
-
* {@link composeHooks} 再把部署槽与 task 槽的**成品**拼一次 —— 单条 4096 相乘后一次注入可达 ~256KB,
|
|
75
|
-
* 而这些字符是**逐次工具调用**进模型可见文本的(core `hooks.js` 的 preToolContext / `prepare-task.js`
|
|
76
|
-
* 的 tool_result 追加 / `runtask.js` 的 Stop follow-up),不是日志。
|
|
77
|
-
*
|
|
78
|
-
* 帽值取 10_000 的依据:模型可见文本在本栈的既有预算量纲就是 10k —— core `truncateError`
|
|
79
|
-
* (`src/core/tool-errors.ts`)的 10k 中截,逐字锚 CC 序列化器 `A7e` 的 `1e4`;#181 战役里 core 还要给
|
|
80
|
-
* `beforeToolCall` block reason 补一道同值兜底闸。取同一量级 = 「一次 hook 注入 ≤ 一份错误文本预算」,
|
|
81
|
-
* 且 4096 的单条帽保证**前两条**钩子的话仍整段进得去(截断是配置广度的代价,不是常态)。
|
|
82
|
-
*/
|
|
83
17
|
export const MAX_HOOK_CONTEXT_TOTAL_CHARS = 10_000;
|
|
84
|
-
/** 帽满时仍值得保留的最短残段(字符)。比这更短的尾巴不是信息:模型会把半句话读成完整指令,
|
|
85
|
-
* 宁可整条落进"省略"计数。 */
|
|
86
18
|
const MIN_HOOK_CONTEXT_PARTIAL_CHARS = 256;
|
|
87
|
-
/** PostToolUse payload 里 tool_response 文本投影的上限(字符)——工具结果可能是 MB 级。 */
|
|
88
19
|
const MAX_TOOL_RESPONSE_CHARS = 32_768;
|
|
89
|
-
/** stdin payload 里 tool_input JSON 投影的上限(字符;巨大 tool_input 经 JSON.stringify 造成
|
|
90
|
-
* 内存/阻塞放大——超限替换为截断标记)。 */
|
|
91
20
|
const MAX_TOOL_INPUT_CHARS = 262_144;
|
|
92
|
-
/**
|
|
93
|
-
* command hook 不再继承完整 `process.env`——worker 进程环境里有模型/DB/service-auth 令牌,
|
|
94
|
-
* 全量透传等于把它们交给任务侧 hook 命令。改成基础运行时 env 的 allowlist(PATH/HOME/SHELL/终端/locale)+
|
|
95
|
-
* CC 约定注入(CLAUDE_PROJECT_DIR)。用户自己要给 hook 的变量走 settings.env(shellEnv,同样单用户闸),
|
|
96
|
-
* 不靠继承服务进程密钥。
|
|
97
|
-
*/
|
|
98
21
|
const HOOK_ENV_ALLOWLIST = [
|
|
99
22
|
"PATH", "HOME", "SHELL", "USER", "LOGNAME", "TERM", "TZ",
|
|
100
23
|
"LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES", "TMPDIR",
|
|
101
24
|
];
|
|
102
|
-
/** hook 可见的 env = 净化基础 allowlist + CLAUDE_PROJECT_DIR + 请求自带的 `settings.env`(shellEnv,
|
|
103
|
-
* 同一单用户闸)——上面注释承诺的"用户变量走 settings.env"通道在这里真兑现(阶段三补齐:
|
|
104
|
-
* 之前 hookEnv 没接 shellEnv,承诺是空头的)。shellEnv 后铺(用户显式给的赢基础值);服务进程密钥
|
|
105
|
-
* 永远不在这张表里。http 条目的 $NAME 插值也从这张表取(见 {@link runHttpHook})。 */
|
|
106
25
|
function hookEnv(ctx) {
|
|
107
26
|
const env = { CLAUDE_PROJECT_DIR: ctx.cwd };
|
|
108
27
|
for (const k of HOOK_ENV_ALLOWLIST) {
|
|
@@ -112,9 +31,6 @@ function hookEnv(ctx) {
|
|
|
112
31
|
}
|
|
113
32
|
return { ...env, ...ctx.shellEnv };
|
|
114
33
|
}
|
|
115
|
-
/** 阶段二点亮的事件 = engine-owned 9 事件全集。与契约 ENGINE_OWNED_HOOK_EVENTS 今天相等但故意独立
|
|
116
|
-
* 声明:契约扩容(新增 engine-owned 事件)时 `hook_events_not_fired` 机制自动把缺口暴露出来,而不是
|
|
117
|
-
* 静默装作点亮。shell-owned 事件不进此表(壳自己跑,owner-single)。 */
|
|
118
34
|
const LIT_EVENTS = [
|
|
119
35
|
"PreToolUse",
|
|
120
36
|
"PostToolUse",
|
|
@@ -126,10 +42,6 @@ const LIT_EVENTS = [
|
|
|
126
42
|
"PreCompact",
|
|
127
43
|
"PostCompact",
|
|
128
44
|
];
|
|
129
|
-
/** core BrainErrorCode(auth/rate_limit/invalid_request/server/network/http,brain/errors 单一来源)→
|
|
130
|
-
* 契约 AssistantTurnError(CC SDKAssistantMessageError 枚举)。network/http 在 CC 枚举里没有对应
|
|
131
|
-
* 类别 → "unknown"(不妄断编造类别;原始人话错误走 error_details)。StopFailure 的 matcher 对映射
|
|
132
|
-
* 【后】的枚举值匹配(契约 HOOK_EVENT_MATCHER_FIELD.StopFailure = "error" = payload 字段)。 */
|
|
133
45
|
const BRAIN_TO_TURN_ERROR = {
|
|
134
46
|
auth: "authentication_failed",
|
|
135
47
|
rate_limit: "rate_limit",
|
|
@@ -140,10 +52,6 @@ const BRAIN_TO_TURN_ERROR = {
|
|
|
140
52
|
function toAssistantTurnError(errorKind) {
|
|
141
53
|
return (errorKind !== undefined ? BRAIN_TO_TURN_ERROR[errorKind] : undefined) ?? "unknown";
|
|
142
54
|
}
|
|
143
|
-
/**
|
|
144
|
-
* 把 wire 上的 untrusted `settings.hooks` 校验成契约 {@link HooksConfig} + 服务侧上限。
|
|
145
|
-
* 错误返回 error 字符串而不 throw——submit 路径 400 fail-loud、resume 防御路径 warn-drop,由调用方选。
|
|
146
|
-
*/
|
|
147
55
|
export function parseHooksConfig(raw) {
|
|
148
56
|
const parsed = HooksConfig.safeParse(raw);
|
|
149
57
|
if (!parsed.success) {
|
|
@@ -155,7 +63,6 @@ export function parseHooksConfig(raw) {
|
|
|
155
63
|
if (!groups)
|
|
156
64
|
continue;
|
|
157
65
|
if (groups.length > MAX_HOOK_MATCHER_GROUPS) {
|
|
158
|
-
// 广度维:组数不设界=条目 cap 可被"多组少条"绕开一半(每组重复扫 matcher)。
|
|
159
66
|
return { error: `settings.hooks.${event}: ${groups.length} matcher groups exceeds cap ${MAX_HOOK_MATCHER_GROUPS}` };
|
|
160
67
|
}
|
|
161
68
|
let entries = 0;
|
|
@@ -166,7 +73,6 @@ export function parseHooksConfig(raw) {
|
|
|
166
73
|
return { error: `settings.hooks.${event}: command exceeds ${MAX_HOOK_COMMAND_CHARS} chars` };
|
|
167
74
|
}
|
|
168
75
|
if (entry.type === "http") {
|
|
169
|
-
// headers/allowedEnvVars 不设界=插值与 fetch header 构造前的内存放大面。
|
|
170
76
|
if (entry.url.length > MAX_HOOK_COMMAND_CHARS) {
|
|
171
77
|
return { error: `settings.hooks.${event}: url exceeds ${MAX_HOOK_COMMAND_CHARS} chars` };
|
|
172
78
|
}
|
|
@@ -190,8 +96,6 @@ export function parseHooksConfig(raw) {
|
|
|
190
96
|
}
|
|
191
97
|
return { config };
|
|
192
98
|
}
|
|
193
|
-
/** CC matcher 语义:精确名、`a|b` 交替、或 JS 正则(锚定全匹配,防 "Bash" 误配 "BashOutput");
|
|
194
|
-
* 空/`*`/缺省匹配一切;非法正则 → 该条不匹配(fail-closed 到"不跑",由调用方记账一次)。 */
|
|
195
99
|
export function hookMatcherMatches(matcher, value) {
|
|
196
100
|
if (matcher === undefined || matcher === "" || matcher === "*")
|
|
197
101
|
return true;
|
|
@@ -204,21 +108,13 @@ export function hookMatcherMatches(matcher, value) {
|
|
|
204
108
|
return "invalid";
|
|
205
109
|
}
|
|
206
110
|
}
|
|
207
|
-
/** `if` 条目条件(CC-exact,对照 CC 2.1.187/198 实测行为核验的真语义,两版一致):
|
|
208
|
-
* 值 = permission-rule 模式(`ToolName` 或 `ToolName(arg-模式)`),【不是】表达式语言——按当前工具调用
|
|
209
|
-
* 求值:名部走 permission matcher(交替/正则同 matcher 字段),括号内 arg-模式(`*` 通配)匹配工具输入的
|
|
210
|
-
* 主字符串(Bash=command,文件工具=file_path/path)。CC 行为逐条目:不匹配 → 跳过该条目(记日志);
|
|
211
|
-
* 非工具事件(payload 无 tool_name)→ "cannot be evaluated" 跳过;解析失败 → 跳过。返回:
|
|
212
|
-
* true=跑 / "not_matched" / "unevaluable"(调用方分别记账;跳过永不静默)。 */
|
|
213
111
|
export function hookIfMatches(cond, payload) {
|
|
214
|
-
// glob→`.*` 拼正则有回溯放大面(长模式×多星号×长主字符串)——模式长度与星号数设上限,
|
|
215
|
-
// 超限=unevaluable(跳过记账;正常 permission-rule 远在限内)。
|
|
216
112
|
if (cond.length > 500 || (cond.match(/\*/g)?.length ?? 0) > 16)
|
|
217
113
|
return "unevaluable";
|
|
218
114
|
const p = (payload ?? {});
|
|
219
115
|
const toolName = typeof p.tool_name === "string" ? p.tool_name : undefined;
|
|
220
116
|
if (!toolName)
|
|
221
|
-
return "unevaluable";
|
|
117
|
+
return "unevaluable";
|
|
222
118
|
const m = /^([^(]+?)(?:\((.*)\))?$/s.exec(cond.trim());
|
|
223
119
|
if (!m)
|
|
224
120
|
return "unevaluable";
|
|
@@ -229,13 +125,12 @@ export function hookIfMatches(cond, payload) {
|
|
|
229
125
|
return "not_matched";
|
|
230
126
|
const spec = m[2];
|
|
231
127
|
if (spec === undefined || spec === "*" || spec === "")
|
|
232
|
-
return true;
|
|
128
|
+
return true;
|
|
233
129
|
const input = p.tool_input;
|
|
234
130
|
const primary = input && (typeof input.command === "string" ? input.command : typeof input.file_path === "string" ? input.file_path : typeof input.path === "string" ? input.path : undefined);
|
|
235
131
|
if (primary === undefined)
|
|
236
|
-
return "unevaluable";
|
|
132
|
+
return "unevaluable";
|
|
237
133
|
try {
|
|
238
|
-
// glob(`*`)→ 锚定正则;其余字符按字面转义(permission specifier 不是裸正则)。
|
|
239
134
|
const re = new RegExp(`^${spec.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
240
135
|
return re.test(primary) ? true : "not_matched";
|
|
241
136
|
}
|
|
@@ -243,27 +138,12 @@ export function hookIfMatches(cond, payload) {
|
|
|
243
138
|
return "unevaluable";
|
|
244
139
|
}
|
|
245
140
|
}
|
|
246
|
-
/** 阶段三b:LLM 条目(prompt+agent 合计)每事件上限——config 滥写不至于把一个工具门拖进 N 次模型调用。 */
|
|
247
141
|
const MAX_LLM_ENTRIES_PER_EVENT = 4;
|
|
248
|
-
/** prompt 条目默认超时(一次判定调用;entry.timeout 覆盖,MAX_HOOK_TIMEOUT_SECONDS 仍是硬顶)。 */
|
|
249
142
|
const DEFAULT_PROMPT_HOOK_TIMEOUT_SECONDS = 30;
|
|
250
|
-
/** agent 条目默认超时(读-only 子代理跑到收敛)。 */
|
|
251
143
|
const DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS = 120;
|
|
252
144
|
function clip(s, n) {
|
|
253
145
|
return s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
|
|
254
146
|
}
|
|
255
|
-
/**
|
|
256
|
-
* hook 上下文片段 → 喂给模型的单串。**所有** `additionalContext` 聚合点的唯一属主(此前是同一个
|
|
257
|
-
* `contexts.join("\n")` 表达式抄在二十余处,总量帽无处可挂 —— 那正是漂移成因)。
|
|
258
|
-
*
|
|
259
|
-
* 三条语义,都是承重的:
|
|
260
|
-
* 1. **顺序保留、只截尾**:先来的钩子先说话;一旦装不下就停,后面的条目一律不再挤进来(哪怕更短)。
|
|
261
|
-
* 按长度重排会让"第 3 条钩子的话"随别人的长度忽隐忽现,排障时无从复现。
|
|
262
|
-
* 2. **截断可见**:尾巴挂一行标记说明省了几条、丢了多少字符。静默丢弃会让 hook 作者以为自己的
|
|
263
|
-
* context 生效了,而模型那头根本没见过 —— 这类"以为配好了"的缺席比长文本本身更贵。
|
|
264
|
-
* 3. **标记不计预算**:与 core `truncateError` / CC `A7e` 同姿势(marker 不占那 10k),否则帽值的
|
|
265
|
-
* 含义会随标记文案长度漂。
|
|
266
|
-
*/
|
|
267
147
|
export function buildHookContext(parts) {
|
|
268
148
|
const present = parts.filter((p) => p.length > 0).map(readHookContextPart);
|
|
269
149
|
if (present.length === 0)
|
|
@@ -275,12 +155,11 @@ export function buildHookContext(parts) {
|
|
|
275
155
|
let omitted = 0;
|
|
276
156
|
let droppedChars = 0;
|
|
277
157
|
for (const part of present) {
|
|
278
|
-
// 内层的账先并进来(标记读回,见 {@link readHookContextPart}),再谈这一轮自己丢了什么。
|
|
279
158
|
messages += part.messages;
|
|
280
159
|
omitted += part.omitted;
|
|
281
160
|
droppedChars += part.dropped;
|
|
282
161
|
if (!truncating) {
|
|
283
|
-
const sep = kept.length > 0 ? 1 : 0;
|
|
162
|
+
const sep = kept.length > 0 ? 1 : 0;
|
|
284
163
|
const room = MAX_HOOK_CONTEXT_TOTAL_CHARS - used - sep;
|
|
285
164
|
if (room >= part.body.length) {
|
|
286
165
|
if (part.body.length > 0) {
|
|
@@ -290,9 +169,6 @@ export function buildHookContext(parts) {
|
|
|
290
169
|
continue;
|
|
291
170
|
}
|
|
292
171
|
truncating = true;
|
|
293
|
-
// 只有**单条**发言可以切尾:切了它仍是"部分在场",账目诚实(dropped 记尾巴,omitted 不变)。
|
|
294
|
-
// 已聚合的成品代表内层多条、正文里却没有条目边界 —— 切一刀会把第 2..N 条整个抹掉却记 0 条省略,
|
|
295
|
-
// 标记恰好在最常走的两层合流路上说谎(codex 复审 R4,亲核属实)。⇒ 它要么整段留,要么整条丢。
|
|
296
172
|
if (part.messages === 1 && room >= MIN_HOOK_CONTEXT_PARTIAL_CHARS) {
|
|
297
173
|
kept.push(part.body.slice(0, room));
|
|
298
174
|
used += sep + room;
|
|
@@ -300,8 +176,6 @@ export function buildHookContext(parts) {
|
|
|
300
176
|
continue;
|
|
301
177
|
}
|
|
302
178
|
}
|
|
303
|
-
// 整条不见 = 它代表的**全部**条目都不见。内层已经报过的那几条上面加过了,这里只补差额
|
|
304
|
-
// ——否则一个"3 of 5"的成品被整条丢掉会记成 8 条,报出"省掉的比总数还多"这种自相矛盾的账。
|
|
305
179
|
omitted += part.messages - part.omitted;
|
|
306
180
|
droppedChars += part.body.length;
|
|
307
181
|
}
|
|
@@ -311,27 +185,8 @@ export function buildHookContext(parts) {
|
|
|
311
185
|
const marker = `[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: ${omitted} of ${messages} hook messages omitted, ${droppedChars} characters dropped]`;
|
|
312
186
|
return kept.length > 0 ? `${body}\n${marker}` : marker;
|
|
313
187
|
}
|
|
314
|
-
/** 截断标记的尾锚形。帽值**逐字写进模式**(不是 `\d+`):只认"我们自己这一版才会写出的那一份",
|
|
315
|
-
* 别处飘来的同类文字连模式都不匹配。 */
|
|
316
188
|
const HOOK_CONTEXT_MARKER_RE = new RegExp(`\\n\\[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: (\\d+) of (\\d+) hook messages omitted, (\\d+) characters dropped\\]$`);
|
|
317
|
-
/** 读回标记时接受的数字上界(2^42)。比任何真值大若干数量级,又小到几十条相加也绝不越过安全整数
|
|
318
|
-
* —— 伪造的巨数因此根本进不来,合并加法也不必每一步复验溢出。 */
|
|
319
189
|
const MAX_HOOK_CONTEXT_PARSED_COUNT = 2 ** 42;
|
|
320
|
-
/**
|
|
321
|
-
* 片段 → 内容 + 它自带的截断账。
|
|
322
|
-
*
|
|
323
|
-
* 为什么必须能**读回**自己写的标记:`composeHooks` 会把 {@link buildHookContext} 的成品当片段再喂一次
|
|
324
|
-
* (部署槽在场即走这条路 —— 是常态不是边角)。标记按内容处理会犯两桩:①切在标记中间 ⇒ 模型读到半截
|
|
325
|
-
* 标记;②外层重新记账 ⇒ 内层丢掉的那一万多字符凭空消失,标记数字变成谎。标记既然不计预算,读回时也
|
|
326
|
-
* 不能算内容;把它携带的账并进外层,函数对自己的输出就是幂等的。
|
|
327
|
-
*
|
|
328
|
-
* 🔴 这把尺子对着的是**不可信文本**(钩子想吐什么就吐什么),所以只认"我们自己才写得出的那一份":
|
|
329
|
-
* 帽值逐字相等(在模式里)、`dropped ≥ 1`(我们从不在零丢弃时写标记)、`omitted ≤ messages`、
|
|
330
|
-
* `messages ≥ 1`、数字都在 {@link MAX_HOOK_CONTEXT_PARSED_COUNT} 内。任一条不合 ⇒ **整段当普通内容**。
|
|
331
|
-
* 认下之后也不会有"内容静默少一行":认下 ⇒ `dropped > 0` ⇒ 输出必然带标记 —— 摘掉的那一行总是被
|
|
332
|
-
* **重写**回去(数字可能被合并),从不是被删掉。伪造得刚好合规只会让记账口径偏,不改任何裁决;
|
|
333
|
-
* 而且钩子配置在单用户闸内,伪造者就是这份账的读者本人。
|
|
334
|
-
*/
|
|
335
190
|
function readHookContextPart(part) {
|
|
336
191
|
const m = HOOK_CONTEXT_MARKER_RE.exec(part);
|
|
337
192
|
if (!m)
|
|
@@ -345,23 +200,19 @@ function readHookContextPart(part) {
|
|
|
345
200
|
}
|
|
346
201
|
return { body: part.slice(0, part.length - m[0].length), messages, omitted, dropped };
|
|
347
202
|
}
|
|
348
|
-
/** {@link buildHookContext} 的字段形(聚合点全是 `...spread` 进结果对象):空 → 不产字段。 */
|
|
349
203
|
function buildHookContextField(parts) {
|
|
350
204
|
const text = buildHookContext(parts);
|
|
351
205
|
return text === undefined ? {} : { additionalContext: text };
|
|
352
206
|
}
|
|
353
|
-
/** 跑一条 command hook:sh -c,stdin=payload JSON(CC-verbatim),超时 SIGKILL,输出各限 1MiB。 */
|
|
354
207
|
function runCommandHook(entry, payload, ctx) {
|
|
355
208
|
return new Promise((resolve) => {
|
|
356
209
|
const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
|
|
357
210
|
let child;
|
|
358
211
|
try {
|
|
359
|
-
|
|
360
|
-
// `sleep 30` 孙进程抓着继承的 stdout 管道,`close` 事件要等它退出——单测实抓的挂死)。
|
|
361
|
-
const sh = hostShell(); // S1:不再硬编码 /bin/sh(Debian=dash 静默错字节),host 同源 bash
|
|
212
|
+
const sh = hostShell();
|
|
362
213
|
child = spawn(sh.shell, [...sh.args, entry.command], {
|
|
363
214
|
cwd: ctx.cwd,
|
|
364
|
-
env: hookEnv(ctx),
|
|
215
|
+
env: hookEnv(ctx),
|
|
365
216
|
stdio: ["pipe", "pipe", "pipe"],
|
|
366
217
|
detached: true,
|
|
367
218
|
});
|
|
@@ -385,14 +236,14 @@ function runCommandHook(entry, payload, ctx) {
|
|
|
385
236
|
timedOut = true;
|
|
386
237
|
try {
|
|
387
238
|
if (child.pid)
|
|
388
|
-
process.kill(-child.pid, "SIGKILL");
|
|
239
|
+
process.kill(-child.pid, "SIGKILL");
|
|
389
240
|
else
|
|
390
241
|
child.kill("SIGKILL");
|
|
391
242
|
}
|
|
392
243
|
catch {
|
|
393
244
|
child.kill("SIGKILL");
|
|
394
245
|
}
|
|
395
|
-
settle(null);
|
|
246
|
+
settle(null);
|
|
396
247
|
}, timeoutMs);
|
|
397
248
|
child.stdout?.on("data", (d) => {
|
|
398
249
|
if (stdout.length < MAX_HOOK_OUTPUT_BYTES)
|
|
@@ -410,29 +261,20 @@ function runCommandHook(entry, payload, ctx) {
|
|
|
410
261
|
}
|
|
411
262
|
});
|
|
412
263
|
child.on("close", (code) => settle(code));
|
|
413
|
-
// stdin=hook 输入 JSON(CC 语义)。EPIPE(hook 不读 stdin 就退出)吞掉——不是错误。
|
|
414
|
-
// 巨大 tool_input 经 boundedToolInputPayload 截断防内存放大。
|
|
415
264
|
child.stdin?.on("error", () => { });
|
|
416
265
|
child.stdin?.end(`${JSON.stringify(boundedToolInputPayload(payload))}\n`);
|
|
417
266
|
});
|
|
418
267
|
}
|
|
419
|
-
/** async 条目:fire-and-forget(不等待、不消费输出、不参与决策)。仍加 detached 进程组 +
|
|
420
|
-
* timeout 整组击杀——否则一个每次工具调用都 spawn 的 async hook 会在长任务里无限堆积不退出的后台进程。
|
|
421
|
-
*
|
|
422
|
-
* asyncRewake(契约:Background + wake the model on exit 2,implies async):与纯 async 的唯一差别=
|
|
423
|
-
* 捕获 stderr(有界)+ close 时 code===2 → ctx.wake(stderr)——CC 的 exit-2-语义在同步条目是"stderr 是给
|
|
424
|
-
* 模型的阻断理由",搬到后台即"stderr 是给模型的唤醒消息"。投递走部署组装的 wake 管道(live TaskStream
|
|
425
|
-
* steer,下个 turn 边界注入);没有活流(任务已结束/挂起)或 wake 缺席 ⇒ warn 记账不重试。 */
|
|
426
268
|
function fireAsyncCommandHook(entry, payload, ctx, event) {
|
|
427
269
|
try {
|
|
428
270
|
const rewake = entry.asyncRewake === true;
|
|
429
271
|
const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
|
|
430
|
-
const sh = hostShell();
|
|
272
|
+
const sh = hostShell();
|
|
431
273
|
const child = spawn(sh.shell, [...sh.args, entry.command], {
|
|
432
274
|
cwd: ctx.cwd,
|
|
433
|
-
env: hookEnv(ctx),
|
|
275
|
+
env: hookEnv(ctx),
|
|
434
276
|
stdio: ["pipe", "ignore", rewake ? "pipe" : "ignore"],
|
|
435
|
-
detached: true,
|
|
277
|
+
detached: true,
|
|
436
278
|
});
|
|
437
279
|
const timer = setTimeout(() => {
|
|
438
280
|
try {
|
|
@@ -452,22 +294,19 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
|
|
|
452
294
|
if (stderr.length < MAX_HOOK_OUTPUT_BYTES)
|
|
453
295
|
stderr += d.toString("utf8").slice(0, MAX_HOOK_OUTPUT_BYTES - stderr.length);
|
|
454
296
|
});
|
|
455
|
-
// pipe 的 socket 句柄会 hold 事件循环——unref 让慢 hook 不拖进程关停(数据事件照收,
|
|
456
|
-
// 超时路径的进程组 SIGKILL 会触发 close 收尾)。
|
|
457
297
|
child.stderr?.unref?.();
|
|
458
298
|
}
|
|
459
299
|
child.on("error", () => clearTimeout(timer));
|
|
460
300
|
child.on("close", (code) => {
|
|
461
301
|
clearTimeout(timer);
|
|
462
302
|
if (!rewake || code !== 2)
|
|
463
|
-
return;
|
|
303
|
+
return;
|
|
464
304
|
const text = stderr.trim();
|
|
465
305
|
if (!ctx.wake) {
|
|
466
306
|
ctx.logger.warn("hook_async_rewake_dropped", { event, reason: "no_wake_pipeline" });
|
|
467
307
|
return;
|
|
468
308
|
}
|
|
469
309
|
if (!text) {
|
|
470
|
-
// exit 2 但 stderr 空:没内容可注入——记账(与同步条目 exit-2-无-stderr 的空理由同类,不造消息)。
|
|
471
310
|
ctx.logger.warn("hook_async_rewake_dropped", { event, reason: "empty_stderr" });
|
|
472
311
|
return;
|
|
473
312
|
}
|
|
@@ -483,7 +322,6 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
|
|
|
483
322
|
child.unref();
|
|
484
323
|
}
|
|
485
324
|
catch {
|
|
486
|
-
/* fire-and-forget:spawn 失败不影响任务 */
|
|
487
325
|
}
|
|
488
326
|
}
|
|
489
327
|
async function runLlmHook(entry, payload, ctx, extra) {
|
|
@@ -494,17 +332,7 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
494
332
|
const timeoutMs = Math.min(entry.timeout ?? defSec, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
|
|
495
333
|
const args = clip(JSON.stringify(boundedToolInputPayload(payload)), MAX_TOOL_INPUT_CHARS);
|
|
496
334
|
const substituted = entry.prompt.split("$ARGUMENTS").join(args);
|
|
497
|
-
// Stop×prompt(CC 形):会话 transcript 在前、被包装过的条件在后 —— 顺序是承重的,
|
|
498
|
-
// 因为 CC 的包装句逐字写着 "Based on the conversation transcript **above**"。
|
|
499
|
-
// CC 形:transcript 在前,条件被 CC 的包装句包起来("Based on the conversation transcript **above**…")。
|
|
500
|
-
// 顺序是承重的 —— 包装句逐字预设会话在上文,放反了那句话本身就是在骗模型。
|
|
501
335
|
const prompt = extra?.transcript !== undefined ? `${extra.transcript}\n\n---\n\n${wrapCondition("Stop", substituted)}` : substituted;
|
|
502
|
-
// 载体外再包一层硬顶:契约说载体自己兜超时,但一个部署组装 bug 不该能挂死工具门(纵深)。
|
|
503
|
-
// A-002.12:硬顶只 unref 不清 ⇒ 每次被门到的工具调用留一只带闭包的定时器,最长挂
|
|
504
|
-
// MAX_HOOK_TIMEOUT_SECONDS+5s=605s(工具门是热路径,一个任务几十上百次)。整个函数收在
|
|
505
|
-
// try/finally 里,任何出口都 clearTimeout。
|
|
506
|
-
// 🔴 硬顶**一只到底**、两次 invoke 共用(语义原样不动):它兜的是「本次 hook 判决整体不许挂死」,
|
|
507
|
-
// 不是「每次载体调用各给一个新窗」——改成 per-invoke 会把最坏墙钟翻倍。
|
|
508
336
|
let hardTopTimer;
|
|
509
337
|
const hardTop = new Promise((r) => {
|
|
510
338
|
hardTopTimer = setTimeout(() => r({ ok: false, error: "hook llm hard-timeout backstop fired (carrier did not settle within timeoutMs+5s — deployment assembly bug)", code: "hard_timeout" }), timeoutMs + 5_000);
|
|
@@ -521,14 +349,9 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
521
349
|
hardTop,
|
|
522
350
|
]).catch((e) => ({ ok: false, error: String(e) }));
|
|
523
351
|
let res = await invoke();
|
|
524
|
-
|
|
525
|
-
// (该拦没拦),而它是**瞬态**的(推理档模型偶尔把额度用在 thinking 上)。
|
|
526
|
-
// ⚠️ 只重试**无内容**,不重试"有内容但读不懂" —— 后者重试一次多半还是读不懂,而且那一格按设计就该放行。
|
|
527
|
-
if (extra && !res.ok && res.code === "no_content") { // B8:判别走码不走文案(v3.1 批2)
|
|
352
|
+
if (extra && !res.ok && res.code === "no_content") {
|
|
528
353
|
ctx.logger.warn("hook_llm_no_content_retry", { event: extra.event });
|
|
529
354
|
res = await invoke();
|
|
530
|
-
// 重试之后**仍然**没有内容 ⇒ 这一轮的守卫确实没能评估。发观测帧(纯 observe,不改变运行)——
|
|
531
|
-
// 方向仍是 fail-open,但用户/壳侧要能知道「这轮没看住」,否则那个放行与「已达成」无法区分。
|
|
532
355
|
if (!res.ok && res.code === "no_content") {
|
|
533
356
|
ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: extra.event, reason: "no_content", detail: "carrier returned no content (after one retry)" });
|
|
534
357
|
}
|
|
@@ -548,14 +371,9 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
548
371
|
async function runHttpHook(entry, payload, ctx) {
|
|
549
372
|
const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
|
|
550
373
|
const allowed = new Set(entry.allowedEnvVars ?? []);
|
|
551
|
-
// 插值源=hookEnv(净化 allowlist + shellEnv),【不是】裸 process.env——否则任务
|
|
552
|
-
// 配置可用 allowedEnvVars 点名服务密钥(MODEL_GATEWAY_KEY 等)插进 header 发往任意 URL。用户要给
|
|
553
|
-
// webhook 的 token 走同请求 settings.env(shellEnv),与 command hook 的 env 姿势同一单源。
|
|
554
374
|
const envSource = hookEnv(ctx);
|
|
555
375
|
const headers = { "content-type": "application/json" };
|
|
556
376
|
for (const [k, v] of Object.entries(entry.headers ?? {})) {
|
|
557
|
-
// $NAME 插值:只认 allowedEnvVars 列出【且】在 hookEnv 表里的名字;其余原样保留(不是插值点,
|
|
558
|
-
// fail-closed 到字面)。
|
|
559
377
|
headers[k] = v.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
|
|
560
378
|
if (!allowed.has(name))
|
|
561
379
|
return whole;
|
|
@@ -573,8 +391,6 @@ async function runHttpHook(entry, payload, ctx) {
|
|
|
573
391
|
signal: ac.signal,
|
|
574
392
|
redirect: "error",
|
|
575
393
|
});
|
|
576
|
-
// body 有界读取(不 res.text() 一口吞——恶意/故障端点可回无限流)。按【字节】
|
|
577
|
-
// 计预算并先截 Uint8Array 再 decode(字符计数挡不住单个巨 chunk 的先行分配),收尾 flush decoder。
|
|
578
394
|
let body = "";
|
|
579
395
|
const reader = res.body?.getReader();
|
|
580
396
|
if (reader) {
|
|
@@ -595,9 +411,6 @@ async function runHttpHook(entry, payload, ctx) {
|
|
|
595
411
|
await reader.cancel().catch(() => undefined);
|
|
596
412
|
}
|
|
597
413
|
if (res.ok) {
|
|
598
|
-
// CC-exact 定谳:http hook 是硬 JSON 契约(≠ command 的 plainText 优雅降级)。
|
|
599
|
-
// - 空 body → 特判 `{}` 成功(无决策,干净放行);
|
|
600
|
-
// - 非空且非 JSON → non-blocking error 记账(不吞成静默通过),stdout 置空让折叠无决策。
|
|
601
414
|
const trimmed = body.trim();
|
|
602
415
|
if (trimmed === "")
|
|
603
416
|
return { code: 0, stdout: "{}", stderr: "", timedOut: false };
|
|
@@ -607,7 +420,6 @@ async function runHttpHook(entry, payload, ctx) {
|
|
|
607
420
|
}
|
|
608
421
|
return { code: 0, stdout: body, stderr: "", timedOut: false };
|
|
609
422
|
}
|
|
610
|
-
// 非 2xx 不 parse body,`HTTP ${status} from ${url}` non-blocking(exit-2 阻断语义不撞)。
|
|
611
423
|
return { code: res.status, stdout: "", stderr: body, timedOut: false };
|
|
612
424
|
}
|
|
613
425
|
catch (e) {
|
|
@@ -619,9 +431,6 @@ async function runHttpHook(entry, payload, ctx) {
|
|
|
619
431
|
clearTimeout(timer);
|
|
620
432
|
}
|
|
621
433
|
}
|
|
622
|
-
/** 输入截断的单值形式:一个 tool_input 值若 stringify 超上限/不可序列化,换成截断/占位标记。
|
|
623
|
-
* 返回原引用=没动(调用方以此保住常态零拷贝)。PostToolBatch 的 tool_calls[].tool_input 逐条走这里
|
|
624
|
-
* (顶层 {@link boundedToolInputPayload} 只看 payload.tool_input,批量形状会绕过它)。 */
|
|
625
434
|
function boundedInputValue(v) {
|
|
626
435
|
let s;
|
|
627
436
|
try {
|
|
@@ -634,8 +443,6 @@ function boundedInputValue(v) {
|
|
|
634
443
|
return v;
|
|
635
444
|
return { __truncated__: true, chars: s.length };
|
|
636
445
|
}
|
|
637
|
-
/** stdin payload 若 stringify 超上限,把 tool_input 替换成截断标记(防内存/阻塞放大)。
|
|
638
|
-
* 只在真的超限时重建对象——常态零拷贝。 */
|
|
639
446
|
function boundedToolInputPayload(payload) {
|
|
640
447
|
if (!payload || typeof payload !== "object")
|
|
641
448
|
return payload;
|
|
@@ -645,7 +452,6 @@ function boundedToolInputPayload(payload) {
|
|
|
645
452
|
const bounded = boundedInputValue(p.tool_input);
|
|
646
453
|
return bounded === p.tool_input ? payload : { ...p, tool_input: bounded };
|
|
647
454
|
}
|
|
648
|
-
/** stdout 的 SyncHookOutput 宽松读取(exit 0 且可解析才有意义;形状错就当没有——CC 同姿势)。 */
|
|
649
455
|
function parseHookStdout(stdout) {
|
|
650
456
|
const t = stdout.trim();
|
|
651
457
|
if (!t.startsWith("{"))
|
|
@@ -658,18 +464,9 @@ function parseHookStdout(stdout) {
|
|
|
658
464
|
return undefined;
|
|
659
465
|
}
|
|
660
466
|
}
|
|
661
|
-
|
|
662
|
-
* 返回每条【同步 command 条目】的运行结果 + 该条目引用(供事件专属的语义映射)。 */
|
|
663
|
-
async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired,
|
|
664
|
-
/** 深度维:本次事件的墙钟截止(epoch ms)——32 条 × 单条 600s 最坏串行卡工具门 ~320min;
|
|
665
|
-
* 到点后不再起【同步】条目(async 旁路不占墙钟照常),记账降级。 */
|
|
666
|
-
deadlineAt,
|
|
667
|
-
/** once 去重键的组维前缀——PreToolUse 逐组合成单组数组调用时必须传真实组号,否则跨组键碰撞。 */
|
|
668
|
-
keyBase,
|
|
669
|
-
/** Stop×prompt 的额外输入(CC 系统提示 + 会话 transcript)。缺席 ⇒ prompt 条目按此前逐位不变。 */
|
|
670
|
-
llmExtra) {
|
|
467
|
+
async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, deadlineAt, keyBase, llmExtra) {
|
|
671
468
|
const results = [];
|
|
672
|
-
let llmEntries = 0;
|
|
469
|
+
let llmEntries = 0;
|
|
673
470
|
let gi = -1;
|
|
674
471
|
for (const group of groups) {
|
|
675
472
|
gi++;
|
|
@@ -687,12 +484,10 @@ llmExtra) {
|
|
|
687
484
|
if (entry.once && onceFired.has(onceKey))
|
|
688
485
|
continue;
|
|
689
486
|
if (entry.type !== "command" && entry.type !== "http" && entry.type !== "prompt" && entry.type !== "agent") {
|
|
690
|
-
// registry-core 未来新增条目类型的前向降级:跳过记账可见,绝不静默。
|
|
691
487
|
ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "type_not_supported" });
|
|
692
488
|
continue;
|
|
693
489
|
}
|
|
694
490
|
if (entry.type === "prompt" || entry.type === "agent") {
|
|
695
|
-
// 阶段三b:载体缺席(部署没组装模型面)/每事件 LLM cap——都跳过记账可见,非阻断。
|
|
696
491
|
const carrier = entry.type === "prompt" ? ctx.hookLlm : ctx.hookAgent;
|
|
697
492
|
if (!carrier) {
|
|
698
493
|
ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "llm_carrier_unavailable" });
|
|
@@ -708,8 +503,6 @@ llmExtra) {
|
|
|
708
503
|
continue;
|
|
709
504
|
}
|
|
710
505
|
if (entry.if !== undefined) {
|
|
711
|
-
// CC-exact(对照 CC 2.1.187/198 实测行为核验,两版一致):if=permission-rule 模式,按当前工具求值;不匹配/评不出
|
|
712
|
-
// → 跳过【该条目】并记日志(CC 行为;取代早期"语义未定忽略照跑"的保守占位)。
|
|
713
506
|
const iv = hookIfMatches(entry.if, payload);
|
|
714
507
|
if (iv !== true) {
|
|
715
508
|
ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: iv === "unevaluable" ? "if_unevaluable" : "if_not_matched", if: clip(entry.if, 200) });
|
|
@@ -717,28 +510,20 @@ llmExtra) {
|
|
|
717
510
|
}
|
|
718
511
|
}
|
|
719
512
|
if (entry.type === "command" && (entry.async || entry.asyncRewake)) {
|
|
720
|
-
// asyncRewake:wake 管道就位后不再降级——exit 2 唤醒在 fireAsyncCommandHook 内兑现(无管道时它自记账)。
|
|
721
513
|
if (entry.once)
|
|
722
514
|
onceFired.add(onceKey);
|
|
723
515
|
fireAsyncCommandHook(entry, payload, ctx, event);
|
|
724
516
|
continue;
|
|
725
517
|
}
|
|
726
518
|
if (Date.now() > deadlineAt) {
|
|
727
|
-
// 事件墙钟预算耗尽——剩余同步条目不再起,警告记账(与超时同姿势:非阻断降级)。
|
|
728
519
|
ctx.logger.warn("hook_event_deadline_exceeded", { event, budgetSec: MAX_HOOK_EVENT_TOTAL_SECONDS });
|
|
729
520
|
return results;
|
|
730
521
|
}
|
|
731
|
-
// once 标记必须在 deadline 检查【后】——预算耗尽被跳过的 once 条目没真跑过,
|
|
732
|
-
// 标了=下次事件永久哑火(可能是安全阻断钩子)。标在 spawn 尝试前:spawn 失败也算消耗
|
|
733
|
-
// (once=一次尝试;失败已 warn 记账,重试语义交给用户重发配置)。
|
|
734
522
|
if (entry.once)
|
|
735
523
|
onceFired.add(onceKey);
|
|
736
524
|
if (entry.type === "prompt" || entry.type === "agent")
|
|
737
525
|
llmEntries++;
|
|
738
526
|
const run = entry.type === "http" ? await runHttpHook(entry, payload, ctx)
|
|
739
|
-
// agent 臂每事件供 CC 两档系统提示(A-065 P2-5:此前恒 undefined=评估者裸跑);顺带获得
|
|
740
|
-
// no_content 一次重试与 CC_EVALUATOR_MAX_OUTPUT_TOKENS(载体不消费则忽略,agent 载体的
|
|
741
|
-
// 输出预算=limits,非 completion 概念)。prompt 臂维持 Stop×prompt 专属 llmExtra(带 transcript)。
|
|
742
527
|
: entry.type === "prompt" || entry.type === "agent" ? await runLlmHook(entry, payload, ctx, entry.type === "prompt" ? llmExtra : { system: ccAgentHookSystemFor(event), event })
|
|
743
528
|
: await runCommandHook(entry, payload, ctx);
|
|
744
529
|
if (run.spawnError) {
|
|
@@ -747,47 +532,28 @@ llmExtra) {
|
|
|
747
532
|
}
|
|
748
533
|
if (run.timedOut) {
|
|
749
534
|
ctx.logger.warn("hook_command_timeout", { event, timeoutSec: Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) });
|
|
750
|
-
continue;
|
|
535
|
+
continue;
|
|
751
536
|
}
|
|
752
537
|
results.push({ entry, run });
|
|
753
538
|
}
|
|
754
539
|
}
|
|
755
540
|
return results;
|
|
756
541
|
}
|
|
757
|
-
/** payload 公共字段(CC BaseHookInput;transcript_path 服务端无壳 jsonl → 空串,契约只定字段名)。 */
|
|
758
542
|
function basePayload(ctx) {
|
|
759
543
|
return {
|
|
760
544
|
session_id: ctx.sessionId,
|
|
761
|
-
// 🔴 **恒空串,而且它有真实后果**(cli 黑板 [1730] 的 live 取证):CC 用这个键让 **command 形**钩子
|
|
762
|
-
// (一个能读磁盘的脚本)自己去读会话 JSONL。本服务是**无状态副本、hook 跑在服务端**,这里
|
|
763
|
-
// **没有那样一个本地文件** ⇒ 给不出真路径。
|
|
764
|
-
// 后果不是"少个字段":用 `type:"prompt"` 写「基于会话状态判断」的钩子,其载体(server 自己发起的
|
|
765
|
-
// 模型调用)对本会话**完全是瞎的** —— cli 真跑实录:模型回「I don't have direct access to your
|
|
766
|
-
// file system…」这类散文 ⇒ `parseHookStdout` 拿不到 decision ⇒ **一次都不拦**。整类 prompt 条件钩子
|
|
767
|
-
// 在这个输入面下**结构上不可能工作**。
|
|
768
|
-
// ⚠️ 正确的修**不是**把路径填上(prompt 载体要的是**内容**不是路径),而是给载体喂会话内容;
|
|
769
|
-
// 那是行为面新增(送多少 / 脱敏口径 / token 记账 / 旋钮 / 喂不到时诚实降级),按本仓规矩先答三问,
|
|
770
|
-
// 不在这里顺手改。**键本身保留**是为了 payload 形状与 CC 对位(CC 恒发这个键)——
|
|
771
|
-
// 值为空是一处**已登记的**偏离,不是遗漏。见 test/hook-transcript-path-gap.test.ts。
|
|
772
545
|
transcript_path: "",
|
|
773
546
|
cwd: ctx.cwd,
|
|
774
547
|
...(ctx.permissionMode ? { permission_mode: ctx.permissionMode } : {}),
|
|
775
548
|
};
|
|
776
549
|
}
|
|
777
|
-
/** matcher=null 事件(契约 HOOK_EVENT_MATCHER_FIELD 为 null:UserPromptSubmit/Stop/PostToolBatch)的
|
|
778
|
-
* 契约语义 = matcher 被忽略、每个条目都跑——把组上的 matcher 抹掉再进公共骨架(否则拿空串当
|
|
779
|
-
* matchValue 会让带 matcher 的条目 fail-closed 漏跑,违背"忽略"语义)。 */
|
|
780
550
|
function ignoreMatchers(groups) {
|
|
781
551
|
return groups.map((g) => (g.matcher === undefined ? g : { ...g, matcher: undefined }));
|
|
782
552
|
}
|
|
783
|
-
/** observe-only 事件(StopFailure/PostCompact——core 回调面返回 void,契约也没定这两个事件的输出面)
|
|
784
|
-
* 的公共收尾:命令照常跑(通知型价值在副作用),非零 exit 记账;stdout 里的决策类输出无处落 →
|
|
785
|
-
* `hook_result_unsupported` warn 不吞(别让用户以为 block/decision 生效了)。 */
|
|
786
553
|
async function runObserveOnlyEvent(event, groups, matchValue, payload, ctx, onceFired) {
|
|
787
554
|
const singles = await runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
|
|
788
555
|
for (const { run } of singles) {
|
|
789
556
|
if (run.code !== 0) {
|
|
790
|
-
// 含 exit 2:这两个事件没有 CC 阻断面,非零一律按 hook 故障记账(非阻断原则)。
|
|
791
557
|
ctx.logger.warn("hook_command_failed", { event, code: run.code, stderr: clip(run.stderr, 300) });
|
|
792
558
|
continue;
|
|
793
559
|
}
|
|
@@ -801,19 +567,12 @@ async function runObserveOnlyEvent(event, groups, matchValue, payload, ctx, once
|
|
|
801
567
|
}
|
|
802
568
|
}
|
|
803
569
|
}
|
|
804
|
-
/**
|
|
805
|
-
* 把校验过的 {@link HooksConfig} 翻成 core `Hooks` 回调(阶段二:engine-owned 9 事件全点亮)。
|
|
806
|
-
* 没有可点亮的条目 → undefined(spec 不挂 hooks,deps 路径原样)。
|
|
807
|
-
*/
|
|
808
570
|
export function createTaskHooks(config, ctx) {
|
|
809
571
|
const configured = Object.keys(config).filter((e) => (config[e]?.length ?? 0) > 0);
|
|
810
|
-
// engine-owned 且本 runner 未点亮的事件(阶段二后=空集,契约扩容时自动复活)才是缺口 → warn。
|
|
811
572
|
const dark = configured.filter((e) => HOOK_EVENT_OWNER[e] === "engine" && !LIT_EVENTS.includes(e));
|
|
812
573
|
if (dark.length > 0) {
|
|
813
574
|
ctx.logger.warn("hook_events_not_fired", { events: dark, phase: 2 });
|
|
814
575
|
}
|
|
815
|
-
// shell-owned 事件的配置到达引擎=正常(owner-single:壳自己跑,引擎绝不 double-fire)——
|
|
816
|
-
// debug 可见记账,不告警(warn 会把合法配置读成缺口)。
|
|
817
576
|
const shellOwned = configured.filter((e) => HOOK_EVENT_OWNER[e] === "shell");
|
|
818
577
|
if (shellOwned.length > 0) {
|
|
819
578
|
ctx.logger.debug("hook_events_shell_owned", { events: shellOwned });
|
|
@@ -841,14 +600,9 @@ export function createTaskHooks(config, ctx) {
|
|
|
841
600
|
let currentInput = input;
|
|
842
601
|
let rewritten = false;
|
|
843
602
|
let asked;
|
|
844
|
-
// #157 fail-open 治理(P 类,tag `server.hooks.pretooluse-unknown-permission-word`):本轮见过
|
|
845
|
-
// 词表外的 `permissionDecision`。安全域闭集词表的 miss 不是"没意见"——钩子作者**表过态**,只是
|
|
846
|
-
// 用了我们不认识的词;把它折成放行等于替他改判。置位后禁止本轮铸 `action:"allow"`(见末尾折叠)。
|
|
847
603
|
let unknownDecision = false;
|
|
848
604
|
const contexts = [];
|
|
849
|
-
const deadlineAt = Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000;
|
|
850
|
-
// 逐条串行(deny 立即短路;updatedInput 串进后续条目 payload——确定性优先,CC 的并行聚合语义
|
|
851
|
-
// 在 deny/ask/allow 折叠上与此等价)。
|
|
605
|
+
const deadlineAt = Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000;
|
|
852
606
|
let gi = -1;
|
|
853
607
|
for (const group of pre) {
|
|
854
608
|
gi++;
|
|
@@ -869,13 +623,12 @@ export function createTaskHooks(config, ctx) {
|
|
|
869
623
|
const singles = await runMatchingEntries("PreToolUse", [{ ...group, matcher: undefined }], toolName, payload, ctx, onceFired, deadlineAt, `PreToolUse:${gi}`);
|
|
870
624
|
for (const { run } of singles) {
|
|
871
625
|
if (run.code === 2) {
|
|
872
|
-
// CC:exit 2 = 阻断,stderr 喂给模型
|
|
873
626
|
const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
|
|
874
627
|
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
875
628
|
}
|
|
876
629
|
if (run.code !== 0) {
|
|
877
630
|
ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
|
|
878
|
-
continue;
|
|
631
|
+
continue;
|
|
879
632
|
}
|
|
880
633
|
const out = parseHookStdout(run.stdout);
|
|
881
634
|
if (!out)
|
|
@@ -884,7 +637,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
884
637
|
ctx.logger.warn("hook_system_message", { event: "PreToolUse", message: clip(out.systemMessage, 500) });
|
|
885
638
|
if (out.continue === false) {
|
|
886
639
|
const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
|
|
887
|
-
// core 的 preToolUse seam 无"终止整个 run"能力(阶段一诚实近似:deny 本次调用)。
|
|
888
640
|
return { action: "deny", message: reason, ...buildHookContextField(contexts) };
|
|
889
641
|
}
|
|
890
642
|
if (out.decision === "block") {
|
|
@@ -911,9 +663,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
911
663
|
}
|
|
912
664
|
}
|
|
913
665
|
else if (decision !== "deny" && decision !== "ask") {
|
|
914
|
-
// 闭集 miss(拼错大小写的 deny / 自造词 / 非字符串 / null)。该条目的裁决**整条不采信**:
|
|
915
|
-
// 它的 updatedInput 也不进串(上面的 if 已排除),并置位禁 allow。CC 的 `allow`/`deny`/
|
|
916
|
-
// `ask` 三词是唯一词表;新词入表须同时进这里,否则它在本仓的语义就是"不采信"。
|
|
917
666
|
unknownDecision = true;
|
|
918
667
|
ctx.logger.warn("hook_permission_decision_unknown", {
|
|
919
668
|
event: "PreToolUse",
|
|
@@ -926,9 +675,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
926
675
|
}
|
|
927
676
|
}
|
|
928
677
|
if (asked !== undefined) {
|
|
929
|
-
// ask 不短路(后续条目可能 deny——deny > ask 已由上面的立即返回保证),折叠成最终 ask。
|
|
930
|
-
// ask 也携带改写(PermissionResult 的 ask 变体同样接受 updatedInput)——否则
|
|
931
|
-
// "改写 + 请示"组合在 approve 后执行的是【原始】args,钩子的 redact/clamp 被静默丢弃。
|
|
932
678
|
return {
|
|
933
679
|
action: "ask",
|
|
934
680
|
message: asked,
|
|
@@ -937,14 +683,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
937
683
|
};
|
|
938
684
|
}
|
|
939
685
|
if (unknownDecision) {
|
|
940
|
-
// #157:本轮有一条**不采信**的裁决 ⇒ 一律折 `ask`(人在环)。两条都不行,各有其害:
|
|
941
|
-
// · `allow` —— 显式放行短路,引擎门不再裁决,等于替钩子作者把 DENY 改判成放行;
|
|
942
|
-
// · `undefined`(无裁决)—— 看着中立,其实**不是** fail-closed(codex 复审 HIGH,2026-08-07):
|
|
943
|
-
// 引擎拿到无裁决就走普通门,而 bypassPermissions / acceptEdits 档下普通门就是放行,
|
|
944
|
-
// 钩子作者写的 DENY 照样跑。词表 miss 的语义是「保护型判词坏了」而不是「钩子没表态」。
|
|
945
|
-
// 已有改写则随 `ask` 投递:改写通常正是 redact/clamp,丢了它 approve 后执行的是【原始】args
|
|
946
|
-
// —— `ask` 是唯一既 fail-closed 又能带 updatedInput 的裁决形。不采信的那条自己的改写不在其中
|
|
947
|
-
// (上面的 if 已把它挡在串外),否则一个坏判词就是一条免审的 args 改写通道。
|
|
948
686
|
return {
|
|
949
687
|
action: "ask",
|
|
950
688
|
message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
|
|
@@ -959,7 +697,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
959
697
|
...buildHookContextField(contexts),
|
|
960
698
|
};
|
|
961
699
|
}
|
|
962
|
-
return undefined;
|
|
700
|
+
return undefined;
|
|
963
701
|
};
|
|
964
702
|
hooks.preToolUse = preToolUseHook;
|
|
965
703
|
}
|
|
@@ -974,14 +712,12 @@ export function createTaskHooks(config, ctx) {
|
|
|
974
712
|
hook_event_name: "PostToolUse",
|
|
975
713
|
tool_name: toolName,
|
|
976
714
|
tool_input: input,
|
|
977
|
-
// 有界文本投影(契约字段是 unknown;整个 details 图可能 MB 级且含不可序列化句柄)。
|
|
978
715
|
tool_response: { isError: output.isError, text: clip(responseText, MAX_TOOL_RESPONSE_CHARS) },
|
|
979
716
|
tool_use_id: tctx.toolCallId,
|
|
980
717
|
};
|
|
981
718
|
const singles = await runMatchingEntries("PostToolUse", post, toolName, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
|
|
982
719
|
for (const { run } of singles) {
|
|
983
720
|
if (run.code === 2) {
|
|
984
|
-
// CC:PostToolUse exit 2 = stderr 作为反馈喂给模型(工具已执行,无从阻断)
|
|
985
721
|
const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
|
|
986
722
|
if (fb)
|
|
987
723
|
contexts.push(fb);
|
|
@@ -997,7 +733,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
997
733
|
if (typeof out.systemMessage === "string")
|
|
998
734
|
ctx.logger.warn("hook_system_message", { event: "PostToolUse", message: clip(out.systemMessage, 500) });
|
|
999
735
|
if (out.continue === false) {
|
|
1000
|
-
// core postToolUse seam 无 run 终止能力;记账可见(阶段一缺口),不吞。
|
|
1001
736
|
ctx.logger.warn("hook_continue_false_unsupported", { event: "PostToolUse" });
|
|
1002
737
|
}
|
|
1003
738
|
if (out.decision === "block" && typeof out.reason === "string")
|
|
@@ -1008,8 +743,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1008
743
|
if (typeof h.additionalContext === "string")
|
|
1009
744
|
contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
|
|
1010
745
|
if (h.updatedMCPToolOutput !== undefined) {
|
|
1011
|
-
// CC 把它限定在 MCP 工具的输出重写;core PostToolUseResult.updatedOutput 期望 content 数组,
|
|
1012
|
-
// 语义不同型 → 阶段一不映射,记账可见。
|
|
1013
746
|
ctx.logger.warn("hook_updated_mcp_output_unsupported", { event: "PostToolUse" });
|
|
1014
747
|
}
|
|
1015
748
|
}
|
|
@@ -1020,8 +753,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1020
753
|
hooks.postToolUse = postToolUseHook;
|
|
1021
754
|
}
|
|
1022
755
|
if (postFailure) {
|
|
1023
|
-
// CC 语义类比 PostToolUse:调用已失败无从阻断——exit 2 的 stderr / decision:block 的 reason 都折成
|
|
1024
|
-
// additionalContext 反馈给模型(core 回调面就是 additionalContext-only,CC-exact)。
|
|
1025
756
|
const postToolUseFailureHook = async (toolName, input, f, tctx) => {
|
|
1026
757
|
const contexts = [];
|
|
1027
758
|
const payload = {
|
|
@@ -1030,9 +761,7 @@ export function createTaskHooks(config, ctx) {
|
|
|
1030
761
|
tool_name: toolName,
|
|
1031
762
|
tool_input: input,
|
|
1032
763
|
tool_use_id: tctx.toolCallId,
|
|
1033
|
-
// 契约 error=模型面错误文本;有界截断(失败结果可能带 MB 级 tool 输出,同 tool_response 姿势)。
|
|
1034
764
|
error: clip(f.error, MAX_TOOL_RESPONSE_CHARS),
|
|
1035
|
-
// core isInterrupt 是宽任意-abort 语义(⊇ CC is_interrupt),原样透传——契约字段名 CC-verbatim。
|
|
1036
765
|
is_interrupt: f.isInterrupt,
|
|
1037
766
|
};
|
|
1038
767
|
const singles = await runMatchingEntries("PostToolUseFailure", postFailure, toolName, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
|
|
@@ -1069,9 +798,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1069
798
|
hooks.postToolUseFailure = postToolUseFailureHook;
|
|
1070
799
|
}
|
|
1071
800
|
if (postBatch) {
|
|
1072
|
-
// matcher=null 事件:契约语义=matcher 被忽略、全条目跑。core 回调面支持 additionalContext(一次性
|
|
1073
|
-
// 注入在 turn 边界)→ exit 2 stderr / decision:block reason 都折进去;契约没定 PostToolBatch 的
|
|
1074
|
-
// hookSpecificOutput 变体 → 出现即记账不吞。
|
|
1075
801
|
const postToolBatchHook = async (calls) => {
|
|
1076
802
|
const contexts = [];
|
|
1077
803
|
const payload = {
|
|
@@ -1079,10 +805,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
1079
805
|
hook_event_name: "PostToolBatch",
|
|
1080
806
|
tool_calls: calls.map((c) => ({
|
|
1081
807
|
tool_name: c.toolName,
|
|
1082
|
-
// 逐条截断(顶层 boundedToolInputPayload 只看 payload.tool_input,批量形状会绕过它)。
|
|
1083
808
|
tool_input: boundedInputValue(c.input),
|
|
1084
809
|
tool_use_id: c.toolCallId,
|
|
1085
|
-
// core 的 response 已是有界文本 digest;投影成与 PostToolUse tool_response 同形({isError,text})。
|
|
1086
810
|
tool_response: { isError: c.isError, ...(c.response !== undefined ? { text: clip(c.response, MAX_TOOL_RESPONSE_CHARS) } : {}) },
|
|
1087
811
|
})),
|
|
1088
812
|
};
|
|
@@ -1116,12 +840,8 @@ export function createTaskHooks(config, ctx) {
|
|
|
1116
840
|
hooks.postToolBatch = postToolBatchHook;
|
|
1117
841
|
}
|
|
1118
842
|
if (promptSubmit) {
|
|
1119
|
-
// matcher=null 事件(全条目跑)。CC:exit 2 = 阻断本次提交(stderr=模型可读理由;core 语义=task 以
|
|
1120
|
-
// 此理由 fail);decision:"block"+reason 同;hookSpecificOutput.additionalContext 注入在 prompt 前。
|
|
1121
843
|
const userPromptSubmitHook = async (prompt) => {
|
|
1122
844
|
const contexts = [];
|
|
1123
|
-
// prompt 是用户任意长输入(粘贴大文件),不设界=绕过 stdin 放大防线——与
|
|
1124
|
-
// tool_input 同一上限截断。
|
|
1125
845
|
const payload = { ...basePayload(ctx), hook_event_name: "UserPromptSubmit", prompt: clip(prompt, MAX_TOOL_INPUT_CHARS) };
|
|
1126
846
|
const singles = await runMatchingEntries("UserPromptSubmit", ignoreMatchers(promptSubmit), "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
|
|
1127
847
|
for (const { run } of singles) {
|
|
@@ -1139,7 +859,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1139
859
|
if (typeof out.systemMessage === "string")
|
|
1140
860
|
ctx.logger.warn("hook_system_message", { event: "UserPromptSubmit", message: clip(out.systemMessage, 500) });
|
|
1141
861
|
if (out.continue === false) {
|
|
1142
|
-
// continue:false=终止整个 run;userPromptSubmit 的 block 语义正是"任务带理由收场"——同义直映射。
|
|
1143
862
|
const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
|
|
1144
863
|
return { block: reason, ...buildHookContextField(contexts) };
|
|
1145
864
|
}
|
|
@@ -1160,40 +879,16 @@ export function createTaskHooks(config, ctx) {
|
|
|
1160
879
|
hooks.userPromptSubmit = userPromptSubmitHook;
|
|
1161
880
|
}
|
|
1162
881
|
if (stopEntries) {
|
|
1163
|
-
// matcher=null 事件(全条目跑)。CC:exit 2 = 阻断停止(stderr=继续干活的理由,模型再来一轮);
|
|
1164
|
-
// decision:"block"+reason 同。防环(cap 8/stopHookActive)由 core 管,runner 只翻译。
|
|
1165
882
|
const stopHook = async (sctx) => {
|
|
1166
883
|
const payload = { ...basePayload(ctx), hook_event_name: "Stop", stop_hook_active: sctx.stopHookActive };
|
|
1167
|
-
// ── Stop × `type:"prompt"` 的 CC 形(逐字文案见 cc-stop-prompt.ts;渲染与证据面判据见 branch-transcript.ts)──
|
|
1168
|
-
// core 1.419 起 `StopHookContext.getBranch()` 交出本次运行的会话分支;没有它,一个"基于会话状态判断"的
|
|
1169
|
-
// 条件钩子**结构上不可能工作**(cli live 实证:模型只会回「I don't have direct access…」这类散文)。
|
|
1170
|
-
//
|
|
1171
|
-
// 🔴 **证据面为空时不进这条路**,这一条是承重的:CC 的提示词**明确指示**模型在证据不足时回
|
|
1172
|
-
// `{"ok": false, "reason": "insufficient evidence in transcript"}`,而 Stop 的 `ok:false` = **拦住停止**。
|
|
1173
|
-
// 那**不是解析失败**(解析会成功、判词有效),所以解析层的任何防线都救不了 —— 唯一的防线是不启动它。
|
|
1174
|
-
// 判据挂在**最终送进模型的那段文本**上,不是挂在 `getBranch` 的存在性上(core [1758]:
|
|
1175
|
-
// 访问器在、返回 `[]`、或者被我们自己的过滤渲没了,是同一个后果,后者还是纯自伤)。
|
|
1176
|
-
//
|
|
1177
|
-
// ── 三问(行为面新增)──
|
|
1178
|
-
// · 谁需要:任何用 `type:"prompt"` 写「基于会话状态判断」的 Stop 条件钩子 —— 这是**整类**,不是某个功能;
|
|
1179
|
-
// · 谁被伤:①会话内容多进一次**已经在发生**的模型调用(增量是 transcript 的 token,不是新增一次调用);
|
|
1180
|
-
// ②今天"从不拦"的钩子会开始按配置拦 —— 那**正是操作方配置它要做的事**,不是意外;
|
|
1181
|
-
// · 什么补偿:`SEMA_STOP_PROMPT_TRANSCRIPT=0` 显式关;transcript 走 `redactSecrets` 与 fleet 行同纪律;
|
|
1182
|
-
// 上限 `BRANCH_TRANSCRIPT_MAX_CHARS` 且截断**明说**;最坏情况仍被 core 的 `STOP_HOOK_BLOCK_CAP=8` 兜住。
|
|
1183
884
|
let llmExtra;
|
|
1184
885
|
const wantsPrompt = stopEntries.some((g) => g.hooks.some((h) => h.type === "prompt"));
|
|
1185
886
|
if (wantsPrompt) {
|
|
1186
887
|
if (ctx.stopPromptTranscript === false) {
|
|
1187
|
-
// 旋钮关 ⇒ **整条路不启用**(而不是"退回旧的、送裸条件进去")。
|
|
1188
|
-
// 选这个语义而不是"逐位退回旧行为":旧行为是**已知失效**的(评估者对会话是瞎的),
|
|
1189
|
-
// 退回它等于留下一条"看起来在跑、永远给不出判决"的路 —— 而那正是这次要修掉的东西。
|
|
1190
|
-
// 关掉就干净地不跑,并记账可见。
|
|
1191
888
|
ctx.logger.warn("hook_entries_skipped", { event: "Stop", type: "prompt", reason: "stop_prompt_transcript_disabled" });
|
|
1192
889
|
ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "skipped", detail: "transcript disabled" });
|
|
1193
890
|
}
|
|
1194
891
|
else {
|
|
1195
|
-
// ⚠️ `getBranch()` **抛错**与「分支为空」是两回事(引擎故障 vs 一条还没内容的分支),
|
|
1196
|
-
// 记成同一个原因会让排障时分不清该看哪一边。分开记。
|
|
1197
892
|
let branch;
|
|
1198
893
|
try {
|
|
1199
894
|
branch = await sctx.getBranch?.();
|
|
@@ -1206,43 +901,18 @@ export function createTaskHooks(config, ctx) {
|
|
|
1206
901
|
llmExtra = { system: ccPromptSystemFor("Stop"), event: "Stop", transcript: rendered.text };
|
|
1207
902
|
}
|
|
1208
903
|
else {
|
|
1209
|
-
// 诚实降级:**跳过 prompt 条目**(记账可见),而不是"送个空会话进去看看模型怎么说"。
|
|
1210
904
|
ctx.logger.warn("hook_entries_skipped", { event: "Stop", type: "prompt", reason: `stop_prompt_no_evidence:${rendered.reason}` });
|
|
1211
905
|
ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "skipped", detail: `no evidence: ${rendered.reason}` });
|
|
1212
906
|
}
|
|
1213
907
|
}
|
|
1214
908
|
}
|
|
1215
909
|
const stopGroups = ignoreMatchers(stopEntries);
|
|
1216
|
-
// 拿不到证据面(或旋钮关)⇒ 把 **prompt** 条目滤掉;其余类型(command/http/agent)照跑 ——
|
|
1217
|
-
// 证据面判据只管"需要会话才能判"的那一类,不该让整个 Stop 事件停摆。
|
|
1218
|
-
// 滤在这里而不是在骨架里:骨架是跨事件公共的,这条判据是 Stop×prompt 专属。
|
|
1219
910
|
const effectiveGroups = llmExtra || !wantsPrompt
|
|
1220
911
|
? stopGroups
|
|
1221
912
|
: stopGroups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => h.type !== "prompt") })).filter((g) => g.hooks.length > 0);
|
|
1222
913
|
const singles = await runMatchingEntries("Stop", effectiveGroups, "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000, undefined, llmExtra);
|
|
1223
|
-
// CC 是并行聚合后 continue:false【全局】压过 block("就让它停")——block 若
|
|
1224
|
-
// 短路返回,后到条目的 continue:false 就没机会翻案。改成跑完整匹配集再折叠:首个 block 记下,
|
|
1225
|
-
// 任一 continue:false 最终放行。
|
|
1226
914
|
let block;
|
|
1227
915
|
let sawContinueFalse = false;
|
|
1228
|
-
// ⚠️⚠️ **这个通道带副作用,别随手用**(亲验 core 源码,两版都读过):
|
|
1229
|
-
// `additionalContext` **单独出现**(无 block)时,引擎返回的是**非空** follow-up 消息
|
|
1230
|
-
// (`agent-harness.js:506` 的 `getFollowUpMessages`)⇒ **这一轮不结束**。
|
|
1231
|
-
// · **core < 1.422**:`if (!result?.block)` 那支还会把 `consecutiveBlocks` **清零**
|
|
1232
|
-
// —— 不是"不计入上限",是**抵消掉之前累积的** ⇒ 任何**能持续为真**的条件走这个通道,
|
|
1233
|
-
// 会造出一条 `STOP_HOOK_BLOCK_CAP` **完全管不住**的循环(最坏烧完 maxTurns 缺省 1000)。
|
|
1234
|
-
// · **core ≥ 1.422(本仓下限)**:改成 `if (messages.length === 0 && !result?.block)`
|
|
1235
|
-
// ⇒ **任何非空反馈都计数、不再清零**(与 CC 2.1.220 一致)⇒ 最坏被 cap 8 兜住。
|
|
1236
|
-
// ⚠️ 即便有了 cap,**这个通道仍然会让这一轮不结束** —— 它在功能上与 block 同效(该停的没停),
|
|
1237
|
-
// 只是多了个上限。所以判据不变:**我们自己不得用它去"顺便告知一声"**;
|
|
1238
|
-
// 命令形钩子作者**显式**这么配是他自己的选择(CC 对位)。
|
|
1239
|
-
// 🔴 本仓把 core 下限提到 `^1.422.0` 正是为了这条 —— 因为 command 形的透传是**我们 1.277.3 加的**,
|
|
1240
|
-
// 是我们引入了这个曝露面(见 `test/core-dependency-floor.test.ts` 顶注第二条理由)。
|
|
1241
|
-
// 🔴 `StopHookResult` 是 `{ block?, additionalContext? }` 两个**解耦**的字段(core `core/hooks.d.ts:67`):
|
|
1242
|
-
// `additionalContext` 注入给模型但**不计入**连续 block 上限(CC 2.1.201 对位)。此前本折叠只产出
|
|
1243
|
-
// `block`,于是一条「只想补上下文、不想拦停」的 Stop 钩子输出被**静默丢弃** —— 而紧邻的
|
|
1244
|
-
// UserPromptSubmit 折叠一直是带上它的(同一个 `hookSpecificOutput.additionalContext` 形)。
|
|
1245
|
-
// 对 `/goal` 这类用法这正是常见形(「还差验收用例」既可以拦、也可以只提醒),所以按同构补齐。
|
|
1246
916
|
const contexts = [];
|
|
1247
917
|
for (const { entry, run } of singles) {
|
|
1248
918
|
if (run.code === 2) {
|
|
@@ -1254,32 +924,11 @@ export function createTaskHooks(config, ctx) {
|
|
|
1254
924
|
ctx.logger.warn("hook_command_failed", { event: "Stop", code: run.code, stderr: clip(run.stderr, 300) });
|
|
1255
925
|
continue;
|
|
1256
926
|
}
|
|
1257
|
-
// CC 形的 prompt 条目回的是 `{ok, reason, impossible?}`,不是 `{decision}` —— 分开解析。
|
|
1258
|
-
// 🔴 `parseCcVerdict` 解析不出来 ⇒ undefined ⇒ **无判决**(继续下一条),绝不臆测成 ok:false:
|
|
1259
|
-
// Stop 的 ok:false = 拦停,把"读不懂"当成"没满足"会让一次解析失败变成一次拦停。
|
|
1260
|
-
// ⚠️ **两种判词形都认**,而且它们**无歧义**:CC 形有 `ok`,既有形有 `decision`/`continue`/`hookSpecificOutput`,
|
|
1261
|
-
// 两者的键不重叠。只认 CC 形会**破坏**已经按既有形写好的 prompt 钩子(那是对既有配置的回归),
|
|
1262
|
-
// 而只认既有形就没有 CC 对位。⇒ 先试 CC 形;不是 CC 形就**落到下面的既有解析**,不早退。
|
|
1263
927
|
if (llmExtra && entry.type === "prompt" && parseCcVerdict(run.stdout)) {
|
|
1264
928
|
const v = parseCcVerdict(run.stdout);
|
|
1265
929
|
if (v.ok)
|
|
1266
|
-
continue;
|
|
930
|
+
continue;
|
|
1267
931
|
if (v.impossible) {
|
|
1268
|
-
// 🔴 第三态:**永远不可能达成** ⇒ **真 fail-open:既不 block,也不注入** —— 理由只进日志。
|
|
1269
|
-
//
|
|
1270
|
-
// ⚠️ 我第一版把理由走了 `additionalContext`,想的是"静默放行会让用户以为钩子没生效"。
|
|
1271
|
-
// **那个选择是错的**,core [1766] 指出、我亲读 core 源码核实(1.419.0):
|
|
1272
|
-
// · `additionalContext` 单独出现 ⇒ 引擎返回**非空** follow-up 消息
|
|
1273
|
-
// (`agent-harness.js:506` 的 `getFollowUpMessages`)⇒ **这一轮不结束**;
|
|
1274
|
-
// · 它**不计入** `STOP_HOOK_BLOCK_CAP`,而且 `runtask.js` 里 `if (!result?.block)` 那支
|
|
1275
|
-
// 会把 `consecutiveBlocks` **清零** —— 不是"不计入",是**抵消掉之前累积的**。
|
|
1276
|
-
// ⇒ 一个**能持续为真**的 `impossible`(模型这段时间就是这么判)会:注入 → 续跑 → 又到停车点 →
|
|
1277
|
-
// 又 impossible → 又注入 → 计数又清零……**专为兜住「该停而不停」建的那个上限,在这条道上保护为零**,
|
|
1278
|
-
// 最坏情况被 `maxTurns`(缺省 1000)或墙钟兜住 = **把整个预算烧完**,而且**看起来是在忙不是卡住**。
|
|
1279
|
-
//
|
|
1280
|
-
// ⇒ 教训:**别用一个「带副作用的通道」去传递信息**。我要传的是"钩子判定不可达",
|
|
1281
|
-
// 而我选的通道本身会让运行继续 —— 那正是 `impossible` 要防的事。
|
|
1282
|
-
// 用户想知道"钩子为什么没生效"应该去日志里找,而不是靠一条让运行继续下去的注入来暗示。
|
|
1283
932
|
ctx.logger.warn("hook_stop_condition_impossible", { event: "Stop", reason: clip(v.reason?.trim() ?? "", 500) });
|
|
1284
933
|
continue;
|
|
1285
934
|
}
|
|
@@ -1289,8 +938,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1289
938
|
}
|
|
1290
939
|
const out = parseHookStdout(run.stdout);
|
|
1291
940
|
if (!out) {
|
|
1292
|
-
// 🔴 **两种形都解析不出来 ⇒ 无判决**(绝不臆测成"没满足")。prompt 条目单独记账:
|
|
1293
|
-
// 模型回散文是这条路最常见的失败形,静默丢弃会让排障无从下手。
|
|
1294
941
|
if (llmExtra && entry.type === "prompt") {
|
|
1295
942
|
ctx.logger.warn("hook_llm_unparsed_verdict", { event: "Stop", head: clip(run.stdout.trim(), 200) });
|
|
1296
943
|
ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "unparsed" });
|
|
@@ -1311,8 +958,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1311
958
|
contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
|
|
1312
959
|
}
|
|
1313
960
|
}
|
|
1314
|
-
// continue:false > block(CC 全局优先序)——「就让它停」。⚠️ 此时 additionalContext 也一并丢弃:
|
|
1315
|
-
// 本 run 已经要结束了,注入给"模型的下一轮"的上下文没有下一轮可去。
|
|
1316
961
|
if (sawContinueFalse)
|
|
1317
962
|
return undefined;
|
|
1318
963
|
if (block === undefined && contexts.length === 0)
|
|
@@ -1325,16 +970,12 @@ export function createTaskHooks(config, ctx) {
|
|
|
1325
970
|
hooks.stop = stopHook;
|
|
1326
971
|
}
|
|
1327
972
|
if (stopFailureEntries) {
|
|
1328
|
-
// observe-only(core 回调 void,契约无 StopFailure 输出面);matcher 对契约 error 枚举匹配
|
|
1329
|
-
// (BRAIN_TO_TURN_ERROR 映射后的值);原始人话错误进 error_details(契约可选字段)。
|
|
1330
973
|
const stopFailureHook = async (fctx) => {
|
|
1331
974
|
const turnError = toAssistantTurnError(fctx.errorKind);
|
|
1332
975
|
const payload = {
|
|
1333
976
|
...basePayload(ctx),
|
|
1334
977
|
hook_event_name: "StopFailure",
|
|
1335
978
|
error: turnError,
|
|
1336
|
-
// brain 错误串可能回显 provider 的 Authorization/token/URL 凭据——脱敏后再截断
|
|
1337
|
-
// (与 elicitation/project-memory 同用 trace/redact 单源)。
|
|
1338
979
|
...(fctx.error ? { error_details: clip(redactSecrets(fctx.error), MAX_HOOK_FEEDBACK_CHARS) } : {}),
|
|
1339
980
|
};
|
|
1340
981
|
await runObserveOnlyEvent("StopFailure", stopFailureEntries, turnError, payload, ctx, onceFired);
|
|
@@ -1342,20 +983,16 @@ export function createTaskHooks(config, ctx) {
|
|
|
1342
983
|
hooks.stopFailure = stopFailureHook;
|
|
1343
984
|
}
|
|
1344
985
|
if (preCompactEntries) {
|
|
1345
|
-
// matcher 对 trigger 匹配。core 的 trigger 可为 "forced"(契约枚举只有 auto|manual 的超集)——原样
|
|
1346
|
-
// 透传(matcher "*" 仍可见);block 在 forced 上由 core 忽略并 trace(其契约注明),runner 不重复裁决。
|
|
1347
986
|
const preCompactHook = async (pctx) => {
|
|
1348
987
|
const payload = {
|
|
1349
988
|
...basePayload(ctx),
|
|
1350
989
|
hook_event_name: "PreCompact",
|
|
1351
990
|
trigger: pctx.trigger,
|
|
1352
|
-
// 契约 custom_instructions 是 nullable 必填(CC-verbatim)——没有就送 null,不缺字段。
|
|
1353
991
|
custom_instructions: pctx.customInstructions ?? null,
|
|
1354
992
|
};
|
|
1355
993
|
const singles = await runMatchingEntries("PreCompact", preCompactEntries, pctx.trigger, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
|
|
1356
994
|
for (const { run } of singles) {
|
|
1357
995
|
if (run.code === 2) {
|
|
1358
|
-
// CC:exit 2 = 阻断本次压缩(stderr=理由,core trace 记录)。
|
|
1359
996
|
return { block: clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreCompact hook" };
|
|
1360
997
|
}
|
|
1361
998
|
if (run.code !== 0) {
|
|
@@ -1368,7 +1005,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1368
1005
|
if (typeof out.systemMessage === "string")
|
|
1369
1006
|
ctx.logger.warn("hook_system_message", { event: "PreCompact", message: clip(out.systemMessage, 500) });
|
|
1370
1007
|
if (out.continue === false) {
|
|
1371
|
-
// "终止整个 run"≠"跳过这次压缩"——不偷换成 block,记账可见。
|
|
1372
1008
|
ctx.logger.warn("hook_continue_false_unsupported", { event: "PreCompact" });
|
|
1373
1009
|
}
|
|
1374
1010
|
if (out.decision === "block") {
|
|
@@ -1380,8 +1016,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1380
1016
|
hooks.preCompact = preCompactHook;
|
|
1381
1017
|
}
|
|
1382
1018
|
if (postCompactEntries) {
|
|
1383
|
-
// observe-only(core 回调 void,契约无 PostCompact 输出面);matcher 对 trigger 匹配;
|
|
1384
|
-
// compact_summary 有界截断(压缩摘要可长)。core 的 tokensBefore/After 契约无字段 → 宁缺毋滥。
|
|
1385
1019
|
const postCompactHook = async (cctx) => {
|
|
1386
1020
|
const payload = {
|
|
1387
1021
|
...basePayload(ctx),
|
|
@@ -1395,18 +1029,6 @@ export function createTaskHooks(config, ctx) {
|
|
|
1395
1029
|
}
|
|
1396
1030
|
return hooks;
|
|
1397
1031
|
}
|
|
1398
|
-
/**
|
|
1399
|
-
* 把部署级 hooks 基线(deps.hooks,如 TOOL_TRACE 观测)折进 task 级 hooks——core 是整槽覆盖
|
|
1400
|
-
* (`spec.hooks ?? deps.hooks`),不折叠就静默 shadow 部署观测(runtask gateBaseline 注释点名的坑)。
|
|
1401
|
-
* 语义:部署槽先跑(观测看到原始输入/输出),task 槽后跑;结果合并:
|
|
1402
|
-
* - preToolUse:deny > ask > allow 折叠;updatedInput 串行线程(部署重写喂给 task 槽);context 拼接。
|
|
1403
|
-
* - postToolUse/postToolUseFailure/postToolBatch:双跑,additionalContext 拼接,updatedOutput 取
|
|
1404
|
-
* 后者(task 槽)优先。
|
|
1405
|
-
* - userPromptSubmit/stop:部署 block 优先短路(不再跑 task 槽),否则双跑、context 拼接、task block 生效。
|
|
1406
|
-
* - preCompact:双跑,block 部署优先,additionalInstructions 拼接。
|
|
1407
|
-
* - stopFailure/postCompact(observe-only void):顺序双跑,无返回可折。
|
|
1408
|
-
* 单在则透传。
|
|
1409
|
-
*/
|
|
1410
1032
|
export function composeHooks(deployment, task) {
|
|
1411
1033
|
if (!deployment)
|
|
1412
1034
|
return task;
|
|
@@ -1414,13 +1036,6 @@ export function composeHooks(deployment, task) {
|
|
|
1414
1036
|
const t = task;
|
|
1415
1037
|
const out = {};
|
|
1416
1038
|
if (d.preToolUse || t.preToolUse) {
|
|
1417
|
-
// 🔴 观察标(core 5.20.0 `Hooks.preToolUseObservational`)的传承折:复合体只有在**每一只在场的
|
|
1418
|
-
// `preToolUse` 都声明了纯观察**时才继续声明。方向是 fail-closed 的——
|
|
1419
|
-
// · 漏传承(该声明没声明)= 退回 5.19.0 行为:多一条链条目,跨副本赎回被响亮拒(吵,但不失守);
|
|
1420
|
-
// · 错传承(有真裁决者却声明了)= core 把复合体的判词整条**拒绝采纳**,客户端 `settings.hooks`
|
|
1421
|
-
// 的 deny/ask 静默蒸发 —— 这是安全轴上的静默 fail-open,比原缺陷坏得多。
|
|
1422
|
-
// 故判据是「无人反对」而不是「有人赞成」:任一在场面未声明 ⇒ 复合体不声明。
|
|
1423
|
-
// 缺席的槽不算反对票(`d.preToolUse` 不在 = 部署侧没有面可裁决,不影响 task 侧的性质)。
|
|
1424
1039
|
const bothObservational = (d.preToolUse === undefined || d.preToolUseObservational === true) &&
|
|
1425
1040
|
(t.preToolUse === undefined || t.preToolUseObservational === true);
|
|
1426
1041
|
if (bothObservational)
|
|
@@ -1446,7 +1061,6 @@ export function composeHooks(deployment, task) {
|
|
|
1446
1061
|
}
|
|
1447
1062
|
}
|
|
1448
1063
|
if (asked) {
|
|
1449
|
-
// ask 槽自己没带改写、但前面槽已改写 → 把线程中的改写挂上,approve 才执行改写后 args。
|
|
1450
1064
|
const askedUpdated = asked.updatedInput;
|
|
1451
1065
|
return {
|
|
1452
1066
|
...asked,
|
|
@@ -1499,8 +1113,6 @@ export function composeHooks(deployment, task) {
|
|
|
1499
1113
|
if (d.userPromptSubmit || t.userPromptSubmit) {
|
|
1500
1114
|
out.userPromptSubmit = async (prompt) => {
|
|
1501
1115
|
const rd = d.userPromptSubmit ? await d.userPromptSubmit(prompt) : undefined;
|
|
1502
|
-
// 短路的是**槽序**(部署拦下就不再跑 task 槽),不是预算:`additionalContext` 与 `block` 在 core
|
|
1503
|
-
// 是解耦两字段(见下方 stop 注),原样外送就是一条绕过总帽的道。
|
|
1504
1116
|
if (rd?.block)
|
|
1505
1117
|
return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
|
|
1506
1118
|
const rt = t.userPromptSubmit ? await t.userPromptSubmit(prompt) : undefined;
|
|
@@ -1511,8 +1123,6 @@ export function composeHooks(deployment, task) {
|
|
|
1511
1123
|
};
|
|
1512
1124
|
}
|
|
1513
1125
|
if (d.stop || t.stop) {
|
|
1514
|
-
// 与 userPromptSubmit 同折叠:部署 block 优先短路;否则双跑、additionalContext(CC 2.1.201,与
|
|
1515
|
-
// block 解耦)拼接、task 的 block 生效——`rt ?? rd` 会把"rd 有 context、rt 有 block"折丢一侧。
|
|
1516
1126
|
out.stop = async (sctx) => {
|
|
1517
1127
|
const rd = d.stop ? await d.stop(sctx) : undefined;
|
|
1518
1128
|
if (rd?.block)
|
|
@@ -1525,7 +1135,6 @@ export function composeHooks(deployment, task) {
|
|
|
1525
1135
|
};
|
|
1526
1136
|
}
|
|
1527
1137
|
if (d.preCompact || t.preCompact) {
|
|
1528
|
-
// 双跑(部署观测也想看到每次压缩);block 部署优先,additionalInstructions 拼接(core 语义=追加)。
|
|
1529
1138
|
out.preCompact = async (pctx) => {
|
|
1530
1139
|
const rd = d.preCompact ? await d.preCompact(pctx) : undefined;
|
|
1531
1140
|
const rt = t.preCompact ? await t.preCompact(pctx) : undefined;
|
|
@@ -1540,9 +1149,6 @@ export function composeHooks(deployment, task) {
|
|
|
1540
1149
|
};
|
|
1541
1150
|
}
|
|
1542
1151
|
for (const slot of ["stopFailure", "postCompact", "permissionDenied"]) {
|
|
1543
|
-
// observe-only void 槽:两侧都在=顺序双跑(部署先),无返回可折;单在则透传。
|
|
1544
|
-
// permissionDenied(core 1.257 新增,gate deny 短路观测)也走这里——createTaskHooks 不产它
|
|
1545
|
-
// (PermissionDenied 是 shell-owned 事件),但部署侧 tracer 未来若设它,整槽覆盖不透传就会被 shadow。
|
|
1546
1152
|
const ds = d[slot];
|
|
1547
1153
|
const ts = t[slot];
|
|
1548
1154
|
if (ds && ts) {
|