@sema-agent/client-core 0.30.1 → 0.30.3
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/CHANGELOG.md +65 -0
- package/README.md +2 -1
- package/dist/engineErrorCodes.d.ts +17 -0
- package/dist/engineErrorCodes.js +19 -1
- package/dist/engineWireSdk.js +4 -1
- package/dist/engineWireTarget.d.ts +3 -1
- package/dist/engineWireTarget.js +17 -6
- package/dist/hitl/localAllowRule.d.ts +80 -0
- package/dist/hitl/localAllowRule.js +96 -0
- package/dist/hitl/persistedRulesWire.d.ts +169 -0
- package/dist/hitl/persistedRulesWire.js +236 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +23 -0
- package/dist/principalWire.d.ts +18 -0
- package/dist/principalWire.js +20 -0
- package/dist/request/taskRequest.d.ts +5 -1
- package/dist/request/taskRequest.js +6 -0
- package/dist/sessionMap.d.ts +95 -0
- package/dist/sessionMap.js +73 -0
- package/dist/wireErrorTriage.d.ts +76 -0
- package/dist/wireErrorTriage.js +130 -0
- package/docs/INTEGRATION-CLIENTS.md +11 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,71 @@
|
|
|
16
16
|
> 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
|
|
17
17
|
> `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
|
|
18
18
|
|
|
19
|
+
## 0.30.3
|
|
20
|
+
|
|
21
|
+
- **#244 F3 wire/会话判定上收 · 包半场(A-028.9/.10/.11/.12/.13/.15 族E;新三件
|
|
22
|
+
`principalWire.ts` / `wireErrorTriage.ts` / `sessionMap.ts`,+14 公面运行期导出)**(2026-08-15;
|
|
23
|
+
常驻钉 = `run-client-core-pure-test.mjs` F3E 段 58 checks):
|
|
24
|
+
- 🔴 **BEHAVIOR CHANGE(A-028.10,cli 主会话裁定)**:`engineWireTargetFor()` 的 principal
|
|
25
|
+
在场性判定从裸 truthy 收编为 **trim 判**(新原语 `normalizeWirePrincipal`)——
|
|
26
|
+
**全空白** `SEMA_LIVE_PRINCIPAL`(env 臂)与全空白 `installed.principal`(显式装配臂)
|
|
27
|
+
从「发出全空白 `x-agent-principal` 头」改为「键缺席=不发头(owner-null)」。
|
|
28
|
+
理由:F-011 停发纪律([3279])的语义是「缺席=不发头,绝不铸哨兵值」,全空白值是垃圾值
|
|
29
|
+
伪装在场 —— 壳 `livePrincipal.ts` 早已按 trim 判(裁定=壳语义为正),包侧对齐消除两侧
|
|
30
|
+
判定相反的分脑。同批 `makeEngineWireClient` 的 `!== ''` 判升级为同一把 trim 尺
|
|
31
|
+
(全空白 principal 同罪归缺席)。非空白实值(含空白包围形如 `' alice '`)一字不动。
|
|
32
|
+
- **`wireErrorTriage.ts`(A-028.11/.13 新件)**:turn 错误分型判定半场
|
|
33
|
+
(`classifyTurnWireError` 四臂:http / stream-ended-without-terminal / transport / internal;
|
|
34
|
+
`isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` /
|
|
35
|
+
`drainingRetryDelayMs` / `WIRE_NETWORK_ERROR_PATTERN`)+ scenario 拒绝判型
|
|
36
|
+
(`scenarioDenyFromError`,allowlist 防御过滤单源)。语义 = cli `seamQuery` /
|
|
37
|
+
`engineTarget.isEngineTransportError` / `scenarioNotAllowedCopy` 逐字,人话文案与渲染归端;
|
|
38
|
+
web `turn-error-classify.ts` 第二实现与逐 token 跨仓 parity 门的退役半场归 web(发布帖点名)。
|
|
39
|
+
- **`sessionMap.ts`(A-028.12 新件)**:「客户端会话 id ↔ 引擎会话 id」映射的单一键形
|
|
40
|
+
(`SessionMapRecord` / `EngineSessionEntry`,壳形为正)+ `engineNamespaceKeyFor` +
|
|
41
|
+
两个纯 merge 判定(`mergeSessionMapRecord` / `mergeEngineEntry`,拒写带 reason 出境)+
|
|
42
|
+
`SessionMapStorePort`(存储归端:cli 文件锁/原子写,web localStorage)。
|
|
43
|
+
- **`engineErrorCodes.ts` +3 常量**:`DRAINING_ERROR_CODE` / `SCENARIO_NOT_ALLOWED_ERROR_CODE` /
|
|
44
|
+
`RESUME_AT_ERROR_CODE_PREFIX`(`REWIND_ERROR_CODE_PREFIXES[0]` 改引同源;resume_at 窄形
|
|
45
|
+
刻意不并入 rewind 宽形 —— 自动重发安全性论证只对 resume_at 族成立)。
|
|
46
|
+
- **`applyLiveRequestDefaults` 补 `additionalReadDirectories` 位(A-028.9)**:壳独有真行为
|
|
47
|
+
(#257 配置目录+tmp 族只读宽根)上收 —— 值(广度闸/realpath 判决)归端算,包做
|
|
48
|
+
「缺席时补位」;`LIVE_DEFAULT_FIELDS` 九件→十件,`unregisteredRequestKeys` 门随表跟上。
|
|
49
|
+
|
|
50
|
+
## 0.30.2
|
|
51
|
+
|
|
52
|
+
- **#244 F2 HITL 规则侧上收 · 包半场(A-028.14 + parseLocalAllowRule 硬排期件;hitl/ 新两件,
|
|
53
|
+
+7 公面导出)**(2026-08-14;源形 = cli `src/sema/persistedRulesWire.ts` /
|
|
54
|
+
`src/sema/localAllowRuleWrite.ts` 的纯判定面;常驻门 `scripts/run-rules-side-test.mjs`,收官形 86 checks):
|
|
55
|
+
- **`hitl/persistedRulesWire.ts`** —— 持久规则车道的通用判定半场:
|
|
56
|
+
`persistedRulesLaneAvailable` / `persistedRulesGovernanceAvailable`(能力位双段 gate,
|
|
57
|
+
`permissionRulesRevoke` 存在性 × `permissionRules` 真值合取;baseUrl 由宿主显式传入,env
|
|
58
|
+
缺省读法留壳)· `RulesFailure` + `classifyRulesFailure`(处置分类,404 两支绝不共用一格)·
|
|
59
|
+
`listAllPersistedRules` + `RulesFacade`(keyset 翻页收口:显式 limit 200 / 游标失效丢游标
|
|
60
|
+
重列一次 / 页数硬帽,绝不交半份清单)· `classifySkippedReason`(散文前缀分类)。
|
|
61
|
+
⚠️ 相对 cli 源形收紧两处(codex F2 两轮,各有门回归钉):
|
|
62
|
+
① `listAllPersistedRules` 的 params **不收调用方 cursor**(类型 `Omit<…,'cursor'>` + 运行期
|
|
63
|
+
剥除留痕)—— 源形的 `...params` 会把外来 cursor 原样送出,「列全」从中途起步却报完整清单;
|
|
64
|
+
② 页体 **fail-closed 窄化** —— 源形把坏形 2xx 页(`{rev:9}` 无 rules 数组 / rev 非有限数 /
|
|
65
|
+
nextCursor 空串或非串)静默认证成「完整(空)清单」,治理面据此宣称零规则而活规则不可见;
|
|
66
|
+
现坏形页一律 `{ok:false, failure:{kind:'error'}}`,nextCursor 只认「缺席=终页/非空串=续页」;
|
|
67
|
+
轮三追钉:跨页 rev 漂移(2xx 换 rev = 混合快照)与坏规则行(撤销承重两键 `rule`/`scope`
|
|
68
|
+
非空串,坏行=坏页;展示键不过度收紧,未知附加键照收)同臂 fail-closed。
|
|
69
|
+
- **persist-ack 读口合成一处(A-028.14 收口本体,⚠️ 行为收紧)**:`readRulePersistOutcome`
|
|
70
|
+
的三态判决改从 `readToolApprovalRespondAck` 的结构窄化产物导出 —— `rulePersisted` /
|
|
71
|
+
`ruleRefusal` 从此单一台账。**半形 ack(如 `{rulePersisted:true}` 无 `delivery` /
|
|
72
|
+
`approvalId` / 闭集 `decision`)从「读成 persisted」收紧为 `unknown`**(一张连回执身份都
|
|
73
|
+
不成形的 ack 不驱动「已保存」告知);真 server ack 恒合形,真车道零行为差。门有反向钉。
|
|
74
|
+
- **`hitl/localAllowRule.ts`** —— durable 腿「不再询问」本地落规则的判定骨架
|
|
75
|
+
`parseLocalAllowRule(rule, expectedToolName, deps)`:窄化①(整工具拒)②(工具名对齐)
|
|
76
|
+
③(字面锚,转义星是字面量)⑤(裸解释器/包装器前缀,只对 Bash 族咨询)⑤b(canonical
|
|
77
|
+
危险规则谓词叠加,[3925] P0 跟修的包位)。规则语法(parse/format)与危险谓词经
|
|
78
|
+
`LocalAllowRuleDeps` 注入(parkOwnership deps 同形)—— 谓词表本体(BARE_SHELL_PREFIXES /
|
|
79
|
+
dangerousPatterns)留宿主,包内绝不自建第二份名单。🔴 拒绝集文案是三端可观察行为
|
|
80
|
+
(cli 128 断言套逐字锚),门内逐字节钉。
|
|
81
|
+
- 消费提示(cli 1.0.77 起换装;desktop/web 接 durable 行第三态时直接消费本骨架):壳侧
|
|
82
|
+
`persistedRulesWire` 的 CC settings 三层 fs 读、SDK facade 装配、通知呈现均留壳原位。
|
|
83
|
+
|
|
19
84
|
## 0.30.1
|
|
20
85
|
|
|
21
86
|
- **#244 F1 决断卡链换装批 · 包半场(hitl/armedGateRegistry + hitl/planReviewWire;A-028.3/.4 二段,
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
|
|
|
35
35
|
|
|
36
36
|
## Scope
|
|
37
37
|
|
|
38
|
-
**Version:** 0.30.
|
|
38
|
+
**Version:** 0.30.3
|
|
39
39
|
|
|
40
40
|
- **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
|
|
41
41
|
B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
|
|
@@ -251,6 +251,7 @@ public-surface guard checks that last one).
|
|
|
251
251
|
| `scripts/run-durable-card-display-keys-test.mjs` | The durable approval row's two display keys survive the row→card recast in `surfaceFsApprovalAndDecide`: `governanceForced` stamps on strict `true` only (absence is "no evidence", never `false`), `ruleSuggestions` passes through the same shape-narrowing reader as the live-frame leg and lands on the **read-only** card key — plus a standing pin that the durable leg never stamps the redeemable `ruleSuggestions` card position (the `/decide` body has no rule slot; offering a "don't ask again" option there would be an affordance nothing can honour) |
|
|
252
252
|
| `scripts/run-integration-doc-freshness-test.mjs` | The **integration contract** (`docs/INTEGRATION-CLIENTS.md`) and the **changelog** (`CHANGELOG.md`) checked against the code, because a document with no guard rots — this one had a whole nest of drift found on it within a day of being written. Five directions, each a claim a machine can actually evaluate. (1) *Counting discipline*: the version-anchor row for the guard count may no longer carry a hand-copied number at all — it changes every time a guard is added, and writing it down is planting a timer; the export counts that are still hand-copied (the surface total, the test-hook count, the sentence describing the surface's internal composition, the sum of the sixteen domain rows, and the three sub-counts) are each compared against a value **derived** from `public-export-baseline.json`, which is the drift a human reviewer caught last time. (2) *Coordinates alive*: every `src/` `scripts/` `docs/` path the doc quotes must be on disk **and tracked by git** — on disk is not in the repo, and a doc that points readers at a file living only in its author's working tree sends every clone to nothing. A file landing in the same commit takes a named carve-out that **stops applying** the moment the file is really tracked (it can no longer let anything through, and the guard prints a line asking for it to be deleted) — deliberately not a red, since turning red on the very commit that lands the file would just manufacture a break that only a follow-up commit could clear. (3) *Arm tables*: the `hitl_out_of_slice` row and the `not_in_slice` fenced list must equal, name for name and in **both** directions, the case labels that really fall into those two buckets — read through the **TypeScript AST**, since which bucket an arm lands in is decided by the argument to `nothing(...)` and by nothing a comment says. The extractor is anchored to the one production projector: exactly one function named `eventToSdkMessage`, exactly one `switch (ev.type)` inside it, and no repeated case label — anything else is a broken anchor rather than a verdict, because a second same-shaped switch elsewhere in the file would otherwise overwrite the real one's conclusions and leave the doc agreeing with a switch nobody runs. The list is delimited by a machine-readable fence rather than by section headings, because the same section also names the terminal arms as a counter-example and prose boundaries cannot tell a member from a foil. (4) *Released sections are frozen*: an **append-only ledger** carries every version ever published — its number, the commit it was published from, and the sha256 of its section — and each one is checked, not just the current release, since pinning only the latest would set every earlier version free the moment the next one ships. The ledger cannot vouch for itself either: each recorded hash is **re-derived from that release commit** through git, so editing an old section and its constant together no longer passes — and the commit the row names is in turn checked against the `gitHead` npm recorded at publish time, which is the one value this repository cannot rewrite, so pointing an old version at a freshly written commit does not pass either. The *set* of versions that must be frozen comes from the registry too, so deleting an old row together with its section — which would otherwise remove that version from every set the guard looks at — is red rather than invisible. A failed registry call is classified rather than swallowed, and the classification consults the registry's own status code *before* it considers connection-level symptoms, so an auth refusal whose body happens to mention the network is still red rather than a skip. The version set is compared as full SemVer including prereleases — matching only `x.y.z` would silently drop a published `0.30.0-beta.1` and reopen the very hole this direction closes — and section headings are matched on a whole-version boundary so a stable release cannot bind itself to the release-candidate section sitting above it. Publishing itself is a two-phase protocol rather than a paradox: before a release, exactly one row may be marked pending and must name the current `package.json` version, exempt from the checks whose inputs do not exist yet; once the registry has that version the row must be promoted, so the temporary state cannot survive its own release. And because the pending exemption rests entirely on "this version is not out yet," it is refused outright when the registry cannot be reached to confirm that — an unverifiable premise is not a licence. Three reverse directions close the rest: a section claiming to be released but absent from the ledger, a ledger entry whose section has vanished, and a `package.json` version that was never frozen. Publishing appends a row; it never rewrites one. (6) *Sentinels*: the readers §5a hands hosts for "is this port installed" are checked against what the source actually declares it returns — `hasXxx()` is a `boolean`, the card port / HITL surface / wire target return `T | null`, the `installHost` family returns `T | undefined`. Testing a `null`-returning reader for `!== undefined` is *always true*, and a self-check that passes whether or not the port is installed is worse than none, because hosts retire their own fallback on the strength of it. Both directions are red: an implementation that changes its sentinel without the doc following, and a doc that names the wrong one. The roster covers the zero-argument readers and their `*For` variants alike — a multi-session host reads the variants, so leaving them off would let exactly the surface desktop depends on drift unwatched — and the §5a table and the §8-B checklist line are each checked against the source, because hosts tick the checklist, and a guard that only watches the prose table misses the line people actually follow. (5) *Packaging*: the README ships with the package and opens by pointing hosts at the integration doc, and the checklist names two more files as required reading before an upgrade — all three must really appear in the `npm pack` manifest, or an npm consumer follows a relative link that npmjs rewrites onto a private repository. Missing tooling never takes the whole verdict down with it: when git, npm or the registry is unreachable those legs print the `SKIPPED-SECTION` marker and the rest still judges, while a release commit the ledger names but git cannot resolve is red rather than skipped. The guard says in its own header what it does **not** do: it judges counts, coordinates, arm sets, released bytes and the packing list — whether a sentence is *right* is still for review and for the hosts to report |
|
|
253
253
|
| `scripts/run-type-superset-ledger-test.mjs` | The type/wire **superset ledger** (`docs/type-superset.json`): positions this package adds on top of a CC-shaped contract, each carrying the evidence for what CC's own type surface does or does not have there. Completeness is deliberately uneven and the ledger says so. The `_sema_*` private-key class is checked in **both** directions (a key in the source that never entered the ledger is red, naming key and file; a ledger row whose key left the source is red) — but only for keys written as literals, which is the convention the ledger mandates. A key assembled by string arithmetic is beyond what any static rule can enumerate, so the guard fails closed on every shape it *can* decide (a bare `_sema_` prefix is red wherever it appears, save one pinned guard site) and leaves the rest as a convention violation for review to catch, rather than claiming a completeness it does not have. The two hand-surveyed classes are only checked for coordinate and evidence integrity, never discovered. Both directions read the source through the **TypeScript AST**, not a text scan, and they read two different sets out of it. A *key site* is an identifier, or a string whose whole value is the key — so `'_sema_decision-v2'` is carried whole rather than truncated at the first non-identifier character into some *other* key that happens to be registered. A *mention* is the key appearing inside a longer string, which is prose, not usage. The staleness direction counts key sites only: a comment or a doc sentence left behind after the last real mint site is deleted must not keep the row alive (mutation-proven — with both the comment and the prose string untouched, removing the one real site turns the guard red). And because a prefix can be concatenated or interpolated into a key no static set will ever see, the bare `_sema_` literal is refused outright rather than traced: every occurrence is red except the single inline `startsWith` guard the sanitizer needs, because the set of expressions a bare prefix can travel through on its way to a concatenation is open-ended and enumerating it is always one form behind. Every row's `host` must still resolve, with the key being a real **member of that declaration** rather than a string occurring somewhere in the same file — `governanceForced`/`delegation` each live on two different shapes in one file, and a member commented out is a member deleted, which a text-shaped check happily reads as still present. And the direction worth the most: each machine-form `ccAbsenceEvidence` is re-derived from the row's own `key` — the ledger's recorded string must match that derivation verbatim, since a row quietly witnessing `\bnever_present\b` is green forever while watching nothing (mutation-proven: the same edit passes the unbound form and is caught by the bound one) — and the check runs against the names the installed `@sema-agent/agent-types` `.d.ts` set actually declares, parsed with the TypeScript AST rather than grepped, so a name CC merely mentions in a comment cannot force the row into the manual escape hatch and thereby retire the very witness that was supposed to fire the day CC declares that name for real. That escape hatch is gated by an allowlist living **in the guard**, not the ledger, so claiming it costs a reviewed diff. Missing material never reads as a pass, and the verdict splits by *why* it is missing: no TypeScript parser skips the suite before it starts; a missing `agent-types` still runs and prints the first three directions, then exits **1** when `package.json` declares the mirror but it is not installed — a broken install must not retire the repository's only "the day CC declares this name" alarm, and reporting it as a skip would leave "never evaluated" and "evaluated, no drift" indistinguishable to the runner — and exits 3 only when nothing declares the mirror at all, which is the one case where the direction genuinely does not apply. Either way a run that evaluated no witness is never counted as one that did. When the mirror *is* present its **installed version** is witnessed too (the two declared floors must agree with each other and the installed copy must meet them), since four preflight probes are satisfied by an arbitrarily stale mirror — they prove the extractor speaks, not that it is current. Every direction carries a positive control — known-present CC symbols, a comment-only sample proving the extractor distinguishes declaration from mention, and synthetic corpora fed through the **same** discriminator function the real verdict uses, so a verdict quietly rewritten to return nothing takes its own control down with it |
|
|
254
|
+
| `scripts/run-rules-side-test.mjs` | The persisted-permission-rules lane's shared decision half. The two capability bits are checked as **two independent gates** — a worker can honestly advertise the rules lane while predating the revoke routes, and that shape must *hide* the governance surface rather than render a dead entry. Failure classification is by **disposition, not cause**: the two 404s (route missing vs. dead ticket) never share a bucket, a 503 `rule_import_retry` means *the ticket is still alive* (the opposite handling of a dead one), and a stale-cursor 400 drops the cursor and re-lists from the top exactly once — never resuming a stale keyset, never surfacing a partial governance list, and never paging past the hard cap. The persist-ack reader is **merged into** `readToolApprovalRespondAck`: the three-state verdict (`persisted` / `refused` / `unknown`) is derived only from an ack that passed the package's structural narrowing, and a half-shaped object such as `{rulePersisted: true}` with no `delivery` reads as `unknown` — the pre-merge shell read would have said `persisted`, which is precisely the double-ledger drift this file closes, so that case is pinned in reverse. The local-allow-rule skeleton pins all five narrowings (whole-tool, tool-name match, literal anchor with the escaped-star counter-example, bare interpreter prefix consulted only for Bash, and the canonical dangerous-pattern overlay) **with their refusal strings byte-for-byte** — the cli's 128-assertion suite anchors the same strings, so a one-character edit here changes observable behaviour on three clients — and asserts the parse is a pure function of its input, because the same call backs both "render the option" and "resolve the selected value" |
|
|
254
255
|
| `scripts/run-park-decision-layer-test.mjs` | The decision layer behind the "stuck behind a card" family, shared by every client. A pending row that is **not in the queue** is three states, not one: a bounded, interruptible re-probe loop distinguishes *a decidable row*, *not born yet* (no positive evidence that anything settled — an empty queue proves nothing) and *settled elsewhere*, always probes at least once so a zero budget keeps the pre-fix semantics verbatim, cuts a hung read face off at the window rather than only noticing afterwards, and reports the honest failure when the window is spent instead of inventing a decision. The decision-note reader is likewise three-state: an explicit `noteRecorded: false` outranks an echoed note body, absence renders **no line at all**, and untrusted note text is flattened and bounded before it ever reaches a renderer. Row routing anchors on the deciding quantity — a row carrying `gateKind: "human"` with `toolName: "Write"` is a tool gate, because `human` is the engine's *generic* "someone must decide", not a synonym for a question — and the queue scan refuses to surface a row it cannot positively prove belongs to this session. A chain that fails after the row vanished is split by whether a card was ever presented: decided-elsewhere, or not-its-turn-yet. A row-level single-flight makes "at most one card per pending item" structural rather than incidental. The resume three-way card pins the option **order** (the zero-effect choice sits at index 0, because the frame carries no default-focus field and a stray Enter must not attach or cancel), renders only options the wired verbs can honour, collapses every ambiguous answer to zero action, omits the liveness line entirely when the engine gave no evidence, and — when there is no card lane at all — prints three real routes and exits on a dedicated code rather than reporting success |
|
|
255
256
|
| `scripts/run-selfheal-reopen-test.mjs` | The 409 active-run self-heal decision chain: `governanceForced` narrows on strict `true` only; triage prefers the wire's `pendingGate.kind` and falls back to the status table (an off-table kind is never guessed into a card arm — hands-off plus the honest wording); a first-sight card makes zero closed/reopened claims and a host presentation receipt of `presented: false` demotes the outcome to reopen-failed; park-row ownership is a fail-closed positive proof (own-run ledger or session id — unprovable is not owned); the three gate-identity key literals live in exactly one mint (`hitl/gateIdentity.ts`, AST string-token scan); the armed-gate presentation ledger is per-session; and the `plan_review` reopen arm shares the arm arm's card body, three-state verdict and delivery pipe, consuming the presentation history once a decision is delivered. The same chain also carries the `running` three-way card: both plan-family gate kinds route to the plan arm and all four ask-family kinds to the ask arm (an off-table kind still never gets guessed into either); the card is offered only for verbs that can actually be honoured and a missing presenter means zero action rather than a silent cancel; a steer is sent **exactly once** with its three delivery outcomes worded apart (a `queued` receipt is the wire correcting the triage input, so the named park word decides which card gets reopened, and an unrecognised park word drives neither arm), and a steer failure is split into *provably not delivered* (4xx) and *delivery unknown*, because telling a user to resend a non-idempotent instruction that may already have landed is how duplicates get made. After a user-chosen cancel, "the session is free" is asserted only from a whitelist of terminal states — park states hold the claim, an unrecognised state word is not a release, a failed read is *unknown* rather than a release, and only a 404 counts as one — and the honest timeout line quotes how long it really waited |
|
|
256
257
|
|
|
@@ -88,6 +88,13 @@ export declare const STOP_PARKED = "stop.parked";
|
|
|
88
88
|
export declare const STOP_CONFLICT_CODES: readonly ["stop.park_arbiter_unreachable", "stop.park_resume_won", "stop.not_landed", "stop.not_local", "stop.parked"];
|
|
89
89
|
/** `outputSchema` 任务在重试上限内没能产出合法结构化输出(语义字面就是 CC 那个 subtype 的话)。 */
|
|
90
90
|
export declare const OUTPUT_INVALID = "output.invalid";
|
|
91
|
+
/**
|
|
92
|
+
* `resume_at.*` 单族前缀(A-028.11 单源化,#244 族E):壳 `isResumeAtRejection`(Esc 杀锚后的
|
|
93
|
+
* 一次性去锚自动重发判型)此前持裸字面 `'resume_at.'` —— 收编到本表。🔴 它**刻意窄于**
|
|
94
|
+
* {@link REWIND_ERROR_CODE_PREFIXES}(不含 `rewind_snapshot.`):自动重发的安全性论证只对
|
|
95
|
+
* resume_at 族做过(pre-stream 零副作用),扩到全 rewind 族属行为变更,须另立项。
|
|
96
|
+
*/
|
|
97
|
+
export declare const RESUME_AT_ERROR_CODE_PREFIX = "resume_at.";
|
|
91
98
|
/**
|
|
92
99
|
* 用户**可自解**的操作性错误的码前缀(选错回退目标 / 回退过根 / 快照缺失)。
|
|
93
100
|
* 消费点把 code 附在 message 后便于对账 —— 这一族是「你的操作有问题」,不是「引擎坏了」。
|
|
@@ -96,6 +103,16 @@ export declare const OUTPUT_INVALID = "output.invalid";
|
|
|
96
103
|
export declare const REWIND_ERROR_CODE_PREFIXES: readonly ["resume_at.", "rewind_snapshot."];
|
|
97
104
|
/** 该码是否属 rewind/resume 可自解族。缺席 ⇒ false。 */
|
|
98
105
|
export declare function isRewindFamilyCode(code: string | undefined): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* server 温切 drain 门的 pre-stream 拒收码(503 + `errorCode:"draining"`;server 侧
|
|
108
|
+
* `error:"draining"` 是冻结契约,SDK toApiError 盖成 `errorCode`)。此前壳/包注释各持裸字面。
|
|
109
|
+
*/
|
|
110
|
+
export declare const DRAINING_ERROR_CODE = "draining";
|
|
111
|
+
/**
|
|
112
|
+
* 场景执法拒绝码(service gateScenarioRequest 的 400;SDK `ScenarioNotAllowedError`)。
|
|
113
|
+
* 判型半场见 wireErrorTriage.scenarioDenyFromError;allowlist 渲染归各端。
|
|
114
|
+
*/
|
|
115
|
+
export declare const SCENARIO_NOT_ALLOWED_ERROR_CODE = "scenario_not_allowed";
|
|
99
116
|
/**
|
|
100
117
|
* 15 分钟流帽(server `src/http/sse-log.ts`)。
|
|
101
118
|
* 🔴 **到达 ≠ run 死了** —— 帧自己就说「run 仍然活着」。正确处置 = 按 `Last-Event-ID` 重连续读
|
package/dist/engineErrorCodes.js
CHANGED
|
@@ -121,16 +121,34 @@ export const STOP_CONFLICT_CODES = [
|
|
|
121
121
|
/** `outputSchema` 任务在重试上限内没能产出合法结构化输出(语义字面就是 CC 那个 subtype 的话)。 */
|
|
122
122
|
export const OUTPUT_INVALID = 'output.invalid';
|
|
123
123
|
// ── rewind / resume 族(core 1.292 [833])────────────────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* `resume_at.*` 单族前缀(A-028.11 单源化,#244 族E):壳 `isResumeAtRejection`(Esc 杀锚后的
|
|
126
|
+
* 一次性去锚自动重发判型)此前持裸字面 `'resume_at.'` —— 收编到本表。🔴 它**刻意窄于**
|
|
127
|
+
* {@link REWIND_ERROR_CODE_PREFIXES}(不含 `rewind_snapshot.`):自动重发的安全性论证只对
|
|
128
|
+
* resume_at 族做过(pre-stream 零副作用),扩到全 rewind 族属行为变更,须另立项。
|
|
129
|
+
*/
|
|
130
|
+
export const RESUME_AT_ERROR_CODE_PREFIX = 'resume_at.';
|
|
124
131
|
/**
|
|
125
132
|
* 用户**可自解**的操作性错误的码前缀(选错回退目标 / 回退过根 / 快照缺失)。
|
|
126
133
|
* 消费点把 code 附在 message 后便于对账 —— 这一族是「你的操作有问题」,不是「引擎坏了」。
|
|
127
134
|
* 前缀形(不是成员形)= 开集:这一族里每加一个新码,判别自动跟上。
|
|
128
135
|
*/
|
|
129
|
-
export const REWIND_ERROR_CODE_PREFIXES = [
|
|
136
|
+
export const REWIND_ERROR_CODE_PREFIXES = [RESUME_AT_ERROR_CODE_PREFIX, 'rewind_snapshot.'];
|
|
130
137
|
/** 该码是否属 rewind/resume 可自解族。缺席 ⇒ false。 */
|
|
131
138
|
export function isRewindFamilyCode(code) {
|
|
132
139
|
return typeof code === 'string' && REWIND_ERROR_CODE_PREFIXES.some((p) => code.startsWith(p));
|
|
133
140
|
}
|
|
141
|
+
// ── drain / 场景执法族(A-028.11/.13 单源化,#244 族E,2026-08-15)────────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* server 温切 drain 门的 pre-stream 拒收码(503 + `errorCode:"draining"`;server 侧
|
|
144
|
+
* `error:"draining"` 是冻结契约,SDK toApiError 盖成 `errorCode`)。此前壳/包注释各持裸字面。
|
|
145
|
+
*/
|
|
146
|
+
export const DRAINING_ERROR_CODE = 'draining';
|
|
147
|
+
/**
|
|
148
|
+
* 场景执法拒绝码(service gateScenarioRequest 的 400;SDK `ScenarioNotAllowedError`)。
|
|
149
|
+
* 判型半场见 wireErrorTriage.scenarioDenyFromError;allowlist 渲染归各端。
|
|
150
|
+
*/
|
|
151
|
+
export const SCENARIO_NOT_ALLOWED_ERROR_CODE = 'scenario_not_allowed';
|
|
134
152
|
// ── 流控族(SDK 6.2.0 CB-1/TR-6 的 `error` 臂)───────────────────────────────────────────────
|
|
135
153
|
/**
|
|
136
154
|
* 15 分钟流帽(server `src/http/sse-log.ts`)。
|
package/dist/engineWireSdk.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* principal 时省略该头,迁移后恒出示,对 requirePrincipal 部署是 fail-open 改善,已记账。
|
|
23
23
|
*/
|
|
24
24
|
import { AgentClient } from '@sema-agent/sdk';
|
|
25
|
+
import { normalizeWirePrincipal } from './principalWire.js';
|
|
25
26
|
/** loopback 判定(engineTarget.isLoopbackEngineUrl 逐字镜像;解析失败=非 loopback)。 */
|
|
26
27
|
export function isLoopbackWireUrl(url) {
|
|
27
28
|
try {
|
|
@@ -72,10 +73,12 @@ export function wireAuthTokenFor(baseUrl, token) {
|
|
|
72
73
|
*/
|
|
73
74
|
export function makeEngineWireClient(cfg) {
|
|
74
75
|
try {
|
|
76
|
+
const principal = normalizeWirePrincipal(cfg.principal);
|
|
75
77
|
const base = {
|
|
76
78
|
baseUrl: cfg.baseUrl,
|
|
77
79
|
// F-011 停发:缺席/空串=不给键(SDK 6.11 缺席=不发 x-agent-principal 头,owner-null)。
|
|
78
|
-
|
|
80
|
+
// A-028.10(0.30.3):空串判升级为 trim 判(principalWire 同尺)——全空白值同罪,归缺席。
|
|
81
|
+
...(principal !== undefined ? { principal } : {}),
|
|
79
82
|
...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
|
|
80
83
|
maxRetries: cfg.maxRetries ?? 0,
|
|
81
84
|
...(cfg.fetchImpl ? { fetch: cfg.fetchImpl } : {}),
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export interface EngineWireTarget {
|
|
2
2
|
baseUrl: string;
|
|
3
3
|
token?: string;
|
|
4
|
-
/** 缺席=不发 x-agent-principal 头(owner-null,F-011 停发);显式装配/env 值恒赢。
|
|
4
|
+
/** 缺席=不发 x-agent-principal 头(owner-null,F-011 停发);显式装配/env 值恒赢。
|
|
5
|
+
* A-028.10(0.30.3):在场性按 trim 判(principalWire 原语)——全空白值在**读出口**归缺席,
|
|
6
|
+
* 绝不发全空白头(裁定=壳 livePrincipal trim 语义为正)。 */
|
|
5
7
|
principal?: string;
|
|
6
8
|
}
|
|
7
9
|
/** 装/卸引擎 wire 目标。传 null 卸回 env 派生。返回还原函数。 */
|
package/dist/engineWireTarget.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { hostEnv } from './hostEnv.js';
|
|
21
21
|
import { createSessionSlot, DEFAULT_SESSION_KEY } from './sessionSlot.js';
|
|
22
|
+
import { normalizeWirePrincipal } from './principalWire.js';
|
|
22
23
|
/** 显式装配(非 Node 宿主唯一的入口;装了就**优先于** env,便于桌面/web 一页多引擎)。
|
|
23
24
|
* W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层。 */
|
|
24
25
|
const installedByKey = createSessionSlot();
|
|
@@ -47,19 +48,29 @@ export function engineWireTarget() {
|
|
|
47
48
|
*/
|
|
48
49
|
export function engineWireTargetFor(sessionKey) {
|
|
49
50
|
const installed = installedByKey.get(sessionKey) ?? null;
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// A-028.10 显式装配臂(0.30.3 BEHAVIOR CHANGE):装配值里全空白 principal 在读出口归缺席
|
|
52
|
+
// (键不出现)——与 env 臂同一把 trim 尺;其余键原样。非空白值(含带空白包围的实值)一字不动。
|
|
53
|
+
if (installed !== null) {
|
|
54
|
+
const p = normalizeWirePrincipal(installed.principal);
|
|
55
|
+
if (p === installed.principal)
|
|
56
|
+
return installed;
|
|
57
|
+
const { principal: _dropped, ...rest } = installed;
|
|
58
|
+
return rest;
|
|
59
|
+
}
|
|
52
60
|
const env = hostEnv();
|
|
53
61
|
const baseUrl = env.SEMA_LIVE_BASEURL;
|
|
54
62
|
if (!baseUrl)
|
|
55
63
|
return null;
|
|
64
|
+
// principal must match the run's owner (stamped by the live stream) — the owner-gated routes answer
|
|
65
|
+
// 404 for non-owners (no existence oracle). F-011 停发([3279]):缺席=不发头,owner-null 两侧
|
|
66
|
+
// 对齐(server 7.8.1 读写面窄互认盖存量);显式 SEMA_LIVE_PRINCIPAL 恒赢,绝不铸哨兵值。
|
|
67
|
+
// A-028.10(0.30.3 BEHAVIOR CHANGE):裸 truthy → trim 判定 —— 全空白 env 值此前会发出
|
|
68
|
+
// 全空白 x-agent-principal 头(垃圾值伪装在场),现归缺席(键不出现),与壳 livePrincipal 同尺。
|
|
69
|
+
const envPrincipal = normalizeWirePrincipal(env.SEMA_LIVE_PRINCIPAL);
|
|
56
70
|
return {
|
|
57
71
|
baseUrl,
|
|
58
|
-
// principal must match the run's owner (stamped by the live stream) — the owner-gated routes answer
|
|
59
|
-
// 404 for non-owners (no existence oracle). F-011 停发([3279]):缺席=不发头,owner-null 两侧
|
|
60
|
-
// 对齐(server 7.8.1 读写面窄互认盖存量);显式 SEMA_LIVE_PRINCIPAL 恒赢,绝不铸哨兵值。
|
|
61
72
|
...(env.SEMA_LIVE_TOKEN ? { token: env.SEMA_LIVE_TOKEN } : {}),
|
|
62
|
-
...(
|
|
73
|
+
...(envPrincipal !== undefined ? { principal: envPrincipal } : {}),
|
|
63
74
|
};
|
|
64
75
|
}
|
|
65
76
|
/** 诊断开关(壳侧 `process.env.SEMA_DEBUG` 的等价读;本包零 process)。 */
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hitl/localAllowRule.ts — durable park 腿「不再询问」第三态的**本地落规则判定骨架**
|
|
3
|
+
* (#244 F2 上收件,clay 硬排期;源形 = cli `src/sema/localAllowRuleWrite.ts` 的
|
|
4
|
+
* `parseLocalAllowRule` + 窄化五步。settings 写入、内存权限态、托管策略读口全部留宿主)。
|
|
5
|
+
*
|
|
6
|
+
* ── 为什么这半场是三端公共的 ────────────────────────────────────────────────────────────────
|
|
7
|
+
* durable park 腿的候选走行上的 `PendingCheckpoint.ruleSuggestions` → 卡入参**只读键**
|
|
8
|
+
* `ruleSuggestionsReadOnly`,而 `/decide` 的体(SDK `AskDecisionBody`)是**闭集、无规则位**——
|
|
9
|
+
* 兑付口只能是**客户端本地** settings。哪些候选可本地兑付、写下去的 canonical 字节是什么,
|
|
10
|
+
* 是纯**形**判定:desktop/web 接到同一份行供给时要的正是同一把窄化(各写各的必漂,而漏一步
|
|
11
|
+
* 就是跨会话任意代码放行 —— [3925] P0 解释器族绕过案的教训)。
|
|
12
|
+
*
|
|
13
|
+
* ── 🔴 五道窄化(本地写入独有;wire 兑付腿没有,那条只搬字节给 server)────────────────────────
|
|
14
|
+
* 1. **整工具规则不写**。`Bash` / `Bash(*)` 解析后退化成整工具 allow,比这只 ask 的动词宽得多
|
|
15
|
+
* (CC `suppressAlwaysAllowRule` 同理;durable 行上没有这一位过境,按**收紧方向**自守)。
|
|
16
|
+
* 2. **规则得是给这只 ask 的那个工具的**(`expectedToolName`)。规则文本是**引擎**铸的,可能用
|
|
17
|
+
* 引擎侧 wire 工具名(`file_write(/tmp/x)` 解析得干干净净,写进 settings 后**永远匹配不上**)
|
|
18
|
+
* —— 落盘不报错、也不生效的死规则,比拒绝更坏。
|
|
19
|
+
* 3. **没有字面锚的规则不写**。`Bash( * )` / `Bash(**)` 等拼法 ruleContent **非空**却与被禁的
|
|
20
|
+
* `Bash(*)` 完全等效(宿主匹配器先 trim 再把未转义 `*` 展成 `.*`)。判据锚在「这条规则还有
|
|
21
|
+
* 没有约束力」:去掉全部**未转义**通配符后必须还剩字面字符(转义星 `\*` 是字面星号,照留)。
|
|
22
|
+
* 5. **解释器/包装器裸前缀不写**(`Bash(bash:*)` / `Bash(sudo:*)` …):过得了窄化③(有字面锚)
|
|
23
|
+
* 却等于放行任意命令(`bash -c "任何东西"`)。谓词经 {@link LocalAllowRuleDeps} 注入 ——
|
|
24
|
+
* 策略表本体(BARE_SHELL_PREFIXES / dangerousPatterns)留宿主,绝不在包内自建第二份名单。
|
|
25
|
+
* 5b. **canonical 危险规则谓词叠加**([3925] P0 跟修):⑤ 只覆盖 shell/wrapper,而
|
|
26
|
+
* python/node/npx/eval/exec/ssh 等**解释器族**的规则形同样等于任意代码放行(`python -c`)。
|
|
27
|
+
* 同经 deps 注入宿主的 `isDangerous{Bash,PowerShell}Permission`(auto-mode 入口同一把谓词)。
|
|
28
|
+
* ⑤ 不撤:它做 basename/.exe 路径归一(`/bin/bash`),canonical 谓词是纯字面形不做归一 ——
|
|
29
|
+
* 两把互补,各管一族拼法。
|
|
30
|
+
* (④ 托管策略是**可变量**,刻意不在本骨架里 —— 解析必须纯:同一串输入永远同一个结论,否则
|
|
31
|
+
* 已上屏的 option value 会反查不到,用户的选择被丢掉。策略由宿主在渲染面与写入面各自查。)
|
|
32
|
+
*
|
|
33
|
+
* 🔴 **纯函数 / 只看形**:不读 settings、不读宿主状态、不读策略;deps 注入的谓词也必须是
|
|
34
|
+
* 纯函数(pattern 表 + 启动期常量),否则「渲档位」与「按 value 反查候选」两处调用会漂。
|
|
35
|
+
* 🔴 **错误文案是契约面**:cli 常驻套(localRuleSuggestionsCard,128+ 断言)对拒绝集逐字锚 ——
|
|
36
|
+
* 上收前后拒绝集必须逐字相等,改一处文案就是改三端的可观察行为。
|
|
37
|
+
* 🔴 UNTRUSTED:`rule` 是 wire 串(core `inlineUntrusted` + server `redactSecrets` 之后)。它只被
|
|
38
|
+
* 喂给宿主自己的规则解析器与 settings 写口,不回喂模型、不进工具入参、不当代码执行。
|
|
39
|
+
*/
|
|
40
|
+
import type { PermissionRuleValue } from '@sema-agent/agent-types';
|
|
41
|
+
export type { PermissionRuleValue };
|
|
42
|
+
/**
|
|
43
|
+
* 宿主注入面(parkOwnership deps 同形:缺一不可的**纯函数**切片,策略表本体留宿主)。
|
|
44
|
+
* · `parseRule` / `formatRule`:宿主的规则语法(CC `Tool(content)` 形 + 转义 + legacy 工具名
|
|
45
|
+
* 归一)。解析失败**抛**(骨架把它折成 `ok:false`);`formatRule` 的产物 = canonical 字节
|
|
46
|
+
* (真正会被写进 settings 的那串,渲染面必须渲它而不是 wire 原文)。
|
|
47
|
+
* · `bashRuleContentHasDangerousBarePrefix`:窄化⑤ 的谓词(宿主 BARE_SHELL_PREFIXES 表,
|
|
48
|
+
* 含 basename/.exe 路径归一)。
|
|
49
|
+
* · `isDangerousBashPermission` / `isDangerousPowerShellPermission`:窄化⑤b 的 canonical 危险
|
|
50
|
+
* 规则谓词(宿主 dangerousPatterns 表;谓词自己按 toolName 分派,非 Bash/PowerShell 恒 false)。
|
|
51
|
+
*/
|
|
52
|
+
export interface LocalAllowRuleDeps {
|
|
53
|
+
parseRule(rule: string): PermissionRuleValue;
|
|
54
|
+
formatRule(value: PermissionRuleValue): string;
|
|
55
|
+
bashRuleContentHasDangerousBarePrefix(ruleContent: string): boolean;
|
|
56
|
+
isDangerousBashPermission(toolName: string, ruleContent: string): boolean;
|
|
57
|
+
isDangerousPowerShellPermission(toolName: string, ruleContent: string): boolean;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 解析结局。`ok:false` = 这条候选**不可本地兑付**(渲染面据此不渲该档)。
|
|
61
|
+
* `canonical` = 真正会被写进 settings 的那串字节(`deps.formatRule` 的产物)——
|
|
62
|
+
* 🔴 渲染面**必须渲它**而不是 wire 原文:两者在带括号的命令上会差一层转义,渲原文就等于
|
|
63
|
+
* 「展示的规则和落地的规则不是同一串」。
|
|
64
|
+
*/
|
|
65
|
+
export type LocalAllowRuleParse = {
|
|
66
|
+
ok: true;
|
|
67
|
+
value: PermissionRuleValue;
|
|
68
|
+
canonical: string;
|
|
69
|
+
} | {
|
|
70
|
+
ok: false;
|
|
71
|
+
error: string;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* 规则原文 → 宿主规则空间的 `PermissionRuleValue`,带文件头注的**形**类窄化(①②③⑤⑤b)。
|
|
75
|
+
*
|
|
76
|
+
* @param expectedToolName 这只 ask 自己的工具名(卡的 `tool.name`)。给了就必须相等 —— 见头注
|
|
77
|
+
* 窄化②:引擎侧 wire 工具名(`file_write` 之类)解析得干净但在宿主的规则空间里是死规则。
|
|
78
|
+
* **缺省不校**:纵深第二道(写口)不知道也不该猜「这只 ask 是哪个工具」,那是卡面的知识。
|
|
79
|
+
*/
|
|
80
|
+
export declare function parseLocalAllowRule(rule: string, expectedToolName: string | undefined, deps: LocalAllowRuleDeps): LocalAllowRuleParse;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** 规则空间里 Bash 工具的名字(窄化⑤ 只对这一族适用;⑤b canonical 谓词管 PS 族)。 */
|
|
2
|
+
const BASH_TOOL_NAME_FOR_RULES = 'Bash';
|
|
3
|
+
/**
|
|
4
|
+
* 规则原文 → 宿主规则空间的 `PermissionRuleValue`,带文件头注的**形**类窄化(①②③⑤⑤b)。
|
|
5
|
+
*
|
|
6
|
+
* @param expectedToolName 这只 ask 自己的工具名(卡的 `tool.name`)。给了就必须相等 —— 见头注
|
|
7
|
+
* 窄化②:引擎侧 wire 工具名(`file_write` 之类)解析得干净但在宿主的规则空间里是死规则。
|
|
8
|
+
* **缺省不校**:纵深第二道(写口)不知道也不该猜「这只 ask 是哪个工具」,那是卡面的知识。
|
|
9
|
+
*/
|
|
10
|
+
export function parseLocalAllowRule(rule, expectedToolName, deps) {
|
|
11
|
+
if (typeof rule !== 'string' || rule === '') {
|
|
12
|
+
return { ok: false, error: 'rule text is empty' };
|
|
13
|
+
}
|
|
14
|
+
let value;
|
|
15
|
+
try {
|
|
16
|
+
value = deps.parseRule(rule);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return { ok: false, error: `rule text did not parse (${String(e)})` };
|
|
20
|
+
}
|
|
21
|
+
if (typeof value.toolName !== 'string' || value.toolName === '') {
|
|
22
|
+
return { ok: false, error: 'rule text carries no tool name' };
|
|
23
|
+
}
|
|
24
|
+
// 窄化①:整工具 allow 比这只 ask 的动词宽得多(CC suppressAlwaysAllowRule 同理)。
|
|
25
|
+
// `Bash(*)` 也走这里 —— 解析器把它归一成整工具形,所以判据只有一条「有没有 ruleContent」。
|
|
26
|
+
if (value.ruleContent === undefined || value.ruleContent === '') {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: 'candidate is a whole-tool allow rule (broader than this ask) — not offered locally',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// 窄化②:规则得是给**这只 ask 的那个工具**的(否则是一条落盘不报错、也永远不生效的死规则)。
|
|
33
|
+
if (expectedToolName !== undefined && value.toolName !== expectedToolName) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: `candidate names tool "${value.toolName}" but this ask is for "${expectedToolName}" — a rule for another tool would never match`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// 窄化③:去掉未转义通配符后必须还剩字面字符,否则这条规则与整工具放行等效(见头注 3)。
|
|
40
|
+
if (!hasLiteralAnchor(value.ruleContent)) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: 'candidate is all wildcard (matches every command for this tool) — equivalent to a whole-tool allow, not offered locally',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// 窄化⑤:解释器/包装器裸前缀(`bash:*` / `env:*` / `sudo:*` …)——有字面锚但等于任意命令放行。
|
|
47
|
+
// 谓词表属主在宿主(bashPermissions 的 BARE_SHELL_PREFIXES),经 deps 注入,绝不自建第二份名单。
|
|
48
|
+
if (value.toolName === BASH_TOOL_NAME_FOR_RULES &&
|
|
49
|
+
deps.bashRuleContentHasDangerousBarePrefix(value.ruleContent)) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: 'candidate is a bare interpreter/wrapper prefix (bash/sh/env/sudo/…) — persisting it would authorize arbitrary commands, not offered locally',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// 窄化⑤b([3925] P0 跟修):canonical 危险规则谓词叠加 —— python/node/npx/eval/exec/ssh 等
|
|
56
|
+
// 解释器族的五种规则形同样等于任意代码放行(`python -c`)。谓词自己按 toolName 分派
|
|
57
|
+
// (非 Bash/PowerShell 恒 false),纯函数,不破坏本函数「同输入恒同结论」的承重性质。
|
|
58
|
+
if (deps.isDangerousBashPermission(value.toolName, value.ruleContent) ||
|
|
59
|
+
deps.isDangerousPowerShellPermission(value.toolName, value.ruleContent)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: 'candidate allow-rule matches a code-execution interpreter/wrapper pattern (python/node/eval/ssh/…) — persisting it would authorize arbitrary code, not offered locally',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, value, canonical: deps.formatRule(value) };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 规则内容里还有没有**字面**约束(窄化③ 的判据)。
|
|
69
|
+
*
|
|
70
|
+
* 只剥**未转义**的 `*`:转义星 `\*` 是字面星号(与宿主匹配器 `hasWildcards` 的判据同源),
|
|
71
|
+
* 剥掉它会把「命令就叫 `*`」这条有约束力的规则误判成通配。剥完 **trim**,空 ⇒ 这条规则对该工具的
|
|
72
|
+
* 任何命令都成立。
|
|
73
|
+
*
|
|
74
|
+
* 末尾的 `trim()` 是承重的,不是顺手:宿主匹配器自己入口也先 `pattern.trim()`,所以「只剩空白」
|
|
75
|
+
* 与「什么都不剩」在匹配语义上是同一件事 —— `Bash(* *)` / `Bash( ** )` 这类「看起来有字符」的
|
|
76
|
+
* 拼法因此同样被拦下(cli 常驻套 ①-17 五种拼法全红)。
|
|
77
|
+
* ⚠️ 仍是**收紧方向**的近似:带一个字面非空白字符的极宽规则(如 `Bash(*a*)`)有字面锚、会放行。
|
|
78
|
+
* 真正按匹配器语义算「这条规则有多宽」要把 per-form 的判定搬过来,记为 follow-up;当前判据不漏
|
|
79
|
+
* 「等效于 `Bash(*)`」的那一族,而那一族才是题面。
|
|
80
|
+
*/
|
|
81
|
+
function hasLiteralAnchor(ruleContent) {
|
|
82
|
+
let out = '';
|
|
83
|
+
for (let i = 0; i < ruleContent.length; i++) {
|
|
84
|
+
const ch = ruleContent[i];
|
|
85
|
+
if (ch === '\\' && i + 1 < ruleContent.length) {
|
|
86
|
+
// 转义序列整段保留(`\*` 的星号是字面量,`\\` 是字面反斜杠)。
|
|
87
|
+
out += ruleContent[i + 1];
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '*')
|
|
92
|
+
continue; // 未转义通配符:剥掉
|
|
93
|
+
out += ch;
|
|
94
|
+
}
|
|
95
|
+
return out.trim() !== '';
|
|
96
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hitl/persistedRulesWire.ts — 持久权限规则(design/179 / design/203)车道的**通用判定半场**
|
|
3
|
+
* (A-028.14 上收件,#244 F2,2026-08-14;源形 = cli `src/sema/persistedRulesWire.ts` 的
|
|
4
|
+
* 纯判定/wire 面;CC settings 三层 fs 读、facade 装配(SDK 短命 client)、通知呈现全部留宿主)。
|
|
5
|
+
*
|
|
6
|
+
* ── 病(census top-08 #1 分脑实证)───────────────────────────────────────────────────────────
|
|
7
|
+
* 包 `hitl/toolApprovalWire.ts` 的 `readToolApprovalRespondAck` 已把 `rulePersisted` /
|
|
8
|
+
* `ruleRefusal` 归一进 `ToolApprovalRespondAck`(delivery/decision 闭集门之后),而 cli 对同两键
|
|
9
|
+
* **另持一份裸读**(入参 `unknown`,无上述两道门)—— 同两键两处读法在跑 = 双份台账,server 增键
|
|
10
|
+
* /改形时两处各漂各的。web 消费同一帧族([3536] 记账)时还会长出第三份。
|
|
11
|
+
* 收口:persist-ack 读口({@link readRulePersistOutcome})与 `readToolApprovalRespondAck`
|
|
12
|
+
* **合成一处** —— 三态判决只从**已过包内结构窄化**的 ack 上导出,不再对 wire 原料二次开读。
|
|
13
|
+
*
|
|
14
|
+
* ── 🔴 能力位 gate,不 trial-by-501 ──────────────────────────────────────────────────────────
|
|
15
|
+
* SDK `resources/rules.d.ts` 头注逐字:`capabilities.permissionRules` 与这台 server **所有**规则口
|
|
16
|
+
* 的 501 是同一条谓词(`PERMISSION_RULES_ENABLED ∧ 规则店 wired`),**按它 gate**,别按版本号猜、
|
|
17
|
+
* 也别拿 501 当探针。撤销面两口(GET/DELETE /v1/rules)自 server 7.12.0 才有路由,而
|
|
18
|
+
* `permissionRules` 比撤销面**早出生** ⇒ 一台 7.11.0 worker 会报 `permissionRules:true` 而这两口
|
|
19
|
+
* 404 `not_found.route`。所以撤销面**另按 `capabilities.permissionRulesRevoke`**:
|
|
20
|
+
* · 在场 ⇒ 路由已铸(答不答仍看 `permissionRules`);
|
|
21
|
+
* · 缺席 ⇒ 这台 worker 比撤销面老 ⇒ **藏起治理入口**(不渲死按钮)。
|
|
22
|
+
* 读法用包内同步口 `engineCapTrue`(engineCapsCache;未判/缺键 = false = 藏,version-safe)。
|
|
23
|
+
* baseUrl 由宿主显式传入(cli 的 `SEMA_LIVE_BASEURL` 缺省读法留壳 —— env 是宿主资产)。
|
|
24
|
+
*
|
|
25
|
+
* ── 🔴 404 禁按成因分支 ──────────────────────────────────────────────────────────────────────
|
|
26
|
+
* redeem 的 404 `not_found.rule_ticket` 是同形信封(伪造/别人的/过期/已用过 + 记录/载荷/确认失败
|
|
27
|
+
* 七类共用一格,wire 上分不出来是设计)。唯一可分的是 503 `state.rule_import_retry`:那一支意味着
|
|
28
|
+
* **票还在**,原样重试即可(与「这张票没了」是相反的处置)。
|
|
29
|
+
*
|
|
30
|
+
* ── UNTRUSTED ───────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
* 规则文本与 `skipped[].reason` 都是引擎/用户 settings 侧的内容,只渲染绝不当代码用;`reason` 是
|
|
32
|
+
* **给人看的散文不是机读码**(SDK 头注),分类只许按第一个 `:` 前缀,并且要容得下**没有前缀**的形。
|
|
33
|
+
*/
|
|
34
|
+
import type { CcImportLayer, CcImportPrepareResult, CcImportRedeemResult, PersistedRule, RuleListParams, RuleListResult, RuleRevokeRequest, RuleRevokeResult } from '@sema-agent/sdk';
|
|
35
|
+
/** 规则车道消费的 `client.rules` 切片(注入缝:宿主给真 SDK facade,测试给假件)。 */
|
|
36
|
+
export interface RulesFacade {
|
|
37
|
+
list(params?: RuleListParams, opts?: {
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}): Promise<RuleListResult>;
|
|
40
|
+
revoke(input: RuleRevokeRequest, opts?: {
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}): Promise<RuleRevokeResult>;
|
|
43
|
+
ccImportPrepare(layers: CcImportLayer[], opts?: {
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}): Promise<CcImportPrepareResult>;
|
|
46
|
+
ccImportRedeem(ticket: string, opts?: {
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}): Promise<CcImportRedeemResult>;
|
|
49
|
+
}
|
|
50
|
+
/** 规则车道在不在(四口 501 同源谓词)。未判/缺键/base 缺席 = false = 整面藏。 */
|
|
51
|
+
export declare function persistedRulesLaneAvailable(baseUrl: string | undefined): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* 治理面(list + revoke)在不在。**两段合取**:
|
|
54
|
+
* · `permissionRulesRevoke` 在场 = 撤销面路由已铸(缺席 = worker 比撤销面老 ⇒ 藏);
|
|
55
|
+
* · `permissionRules` 为真 = 车道真答话(店 + 旋钮)。
|
|
56
|
+
* 只查前者会在「7.11.0 且规则店在」的 worker 上渲出一个恒 404 的治理入口。
|
|
57
|
+
*/
|
|
58
|
+
export declare function persistedRulesGovernanceAvailable(baseUrl: string | undefined): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* 规则口失败的**处置分类**(不是成因分类)。SDK 的 typed 错误族按 `status` + `errorCode` 读,
|
|
61
|
+
* 结构读法(不 instanceof)—— 注入面可能是假件,bundling 下 instanceof 也不该是承重判据。
|
|
62
|
+
*/
|
|
63
|
+
export type RulesFailure =
|
|
64
|
+
/** 501 `capability.rule_store_required` —— 这台部署没接规则店/旋钮关着 ⇒ 整面藏起来。 */
|
|
65
|
+
{
|
|
66
|
+
kind: 'lane-unavailable';
|
|
67
|
+
message: string;
|
|
68
|
+
}
|
|
69
|
+
/** 404 `not_found.route` —— worker 比撤销面老(≤7.11.0)⇒ 治理入口藏起来。 */
|
|
70
|
+
| {
|
|
71
|
+
kind: 'route-missing';
|
|
72
|
+
message: string;
|
|
73
|
+
}
|
|
74
|
+
/** 404 `not_found.rule_ticket` —— 这张票在这里不可用,**禁按成因分支**:丢票,重来一遍。 */
|
|
75
|
+
| {
|
|
76
|
+
kind: 'ticket-dead';
|
|
77
|
+
message: string;
|
|
78
|
+
}
|
|
79
|
+
/** 503 `state.rule_import_retry` —— **票还在**,原样重试(与 ticket-dead 相反的处置)。 */
|
|
80
|
+
| {
|
|
81
|
+
kind: 'retry-same-ticket';
|
|
82
|
+
message: string;
|
|
83
|
+
retryAfterSec?: number;
|
|
84
|
+
}
|
|
85
|
+
/** 503 `state.rule_remove_failed` —— 什么都没写,或结局不定;自己重试后用 list 对账。 */
|
|
86
|
+
| {
|
|
87
|
+
kind: 'retryable';
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
/** 400 `request.query_invalid` —— 游标铸在别的 rev/principal/scope 上 ⇒ 丢游标从头列。 */
|
|
91
|
+
| {
|
|
92
|
+
kind: 'cursor-stale';
|
|
93
|
+
message: string;
|
|
94
|
+
}
|
|
95
|
+
/** 413 —— 候选总数超上限(200):缩短后重发,重试同一份没有意义。 */
|
|
96
|
+
| {
|
|
97
|
+
kind: 'too-many-candidates';
|
|
98
|
+
message: string;
|
|
99
|
+
}
|
|
100
|
+
/** 403 `auth.operator_only` —— 越权读/撤销(替别人)被拒。 */
|
|
101
|
+
| {
|
|
102
|
+
kind: 'forbidden';
|
|
103
|
+
message: string;
|
|
104
|
+
} | {
|
|
105
|
+
kind: 'error';
|
|
106
|
+
message: string;
|
|
107
|
+
};
|
|
108
|
+
export declare function classifyRulesFailure(e: unknown): RulesFailure;
|
|
109
|
+
/** 一次「列全」的结果。`rules` 恒是**完整**清单(翻不完 ⇒ 走 failure,绝不交半份清单)。 */
|
|
110
|
+
export type ListAllPersistedRulesOutcome = {
|
|
111
|
+
ok: true;
|
|
112
|
+
rules: PersistedRule[];
|
|
113
|
+
rev: number;
|
|
114
|
+
} | {
|
|
115
|
+
ok: false;
|
|
116
|
+
failure: RulesFailure;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* 列全一位 principal 名下活着的规则。
|
|
120
|
+
*
|
|
121
|
+
* 🔴 **翻页要翻完**:`nextCursor` 缺席才是终点 —— 半途停下拿到的是一份不全的清单,而治理视图
|
|
122
|
+
* 恰恰最不能拿不全的清单当全量。所以这里 drain 到底,任何一页失败都不交部分结果。
|
|
123
|
+
* 🔴 **游标绑 `(rev, principal, scope)`**:两页之间有人加/删了规则 ⇒ 第二页 400
|
|
124
|
+
* `request.query_invalid`。处置 = **丢游标从头列一次**(静默重置成「接着上一页」会得到一份既漏行
|
|
125
|
+
* 又重行的清单);从头再撞一次 ⇒ 如实报 cursor-stale,由调用方(人按 r 刷新)决定。
|
|
126
|
+
* 🔴 **调用方 cursor 不收**(codex F2 对抗复审 [medium]):类型上剔掉 `cursor` 还不够 —— JS
|
|
127
|
+
* 调用方仍能塞进来,而首页的 `...params` 会把它原样送出 ⇒ 「列全」从**中途**开始却报 `ok:true`
|
|
128
|
+
* 完整清单(治理面据此藏掉仍然生效的规则)。运行期显式剥除 + 留痕:drain 恒从第一页起,
|
|
129
|
+
* 「接着别人的 keyset」证明不了完整性,与本函数的契约(rules 恒完整)结构性冲突。
|
|
130
|
+
*/
|
|
131
|
+
export declare function listAllPersistedRules(facade: RulesFacade, params?: Omit<RuleListParams, 'cursor'>, opts?: {
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
}): Promise<ListAllPersistedRulesOutcome>;
|
|
134
|
+
/**
|
|
135
|
+
* `skipped[].reason` 的分类。**只按第一个 `:` 前缀**,且必须容得下**没有前缀**的两种真值形
|
|
136
|
+
* (整层 JSON 解不开 / 条目不是串)——SDK 头注逐字:reason 是给人看的散文,不是机读码,
|
|
137
|
+
* 全串等值匹配与「裸码」两种读法都会漂。
|
|
138
|
+
* 前缀形判据刻意保守(`RuleRejectCode` 形:小写 + 点/下划线),防把散文里的第一个冒号误读成码。
|
|
139
|
+
*/
|
|
140
|
+
export declare function classifySkippedReason(reason: string): {
|
|
141
|
+
code?: string;
|
|
142
|
+
text: string;
|
|
143
|
+
};
|
|
144
|
+
/** 「不再询问到底存上了没」的三态判决(呈现半场按它渲一行,绝不据它翻转裁决)。 */
|
|
145
|
+
export type RulePersistOutcome = {
|
|
146
|
+
state: 'unknown';
|
|
147
|
+
} | {
|
|
148
|
+
state: 'persisted';
|
|
149
|
+
} | {
|
|
150
|
+
state: 'refused';
|
|
151
|
+
reason?: string;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* respond 回执上「不再询问到底存上了没」的**结构化读口**。
|
|
155
|
+
*
|
|
156
|
+
* 🔴 **单一台账**(A-028.14 收口本体):`rulePersisted` / `ruleRefusal` 的结构窄化只有
|
|
157
|
+
* {@link readToolApprovalRespondAck} 这一份 —— 本函数不对 wire 原料二次开读,ack 过不了包内
|
|
158
|
+
* 结构门(缺 `delivery:'applied'` / `approvalId` / 三词闭集 `decision`)⇒ **`unknown`**:
|
|
159
|
+
* 一张连回执身份都不成形的 ack,不配驱动一行「已保存」的用户告知(诚实缺席优先)。
|
|
160
|
+
* 收口前 cli 的裸读会把 `{rulePersisted:true}` 这类半形对象读成 `persisted` —— 那正是双份台账
|
|
161
|
+
* 各漂各的形,常驻套对这一格有反向钉。
|
|
162
|
+
*
|
|
163
|
+
* 🔴 `rulePersisted` 缺席 ≠ `false`(未带 persistRule 的回决 / 旧 server ⇒ 字段省略);
|
|
164
|
+
* 🔴 规则没存上**从不翻转裁决** —— 200 + `rulePersisted:false` + `ruleRefusal` 是诚实形,
|
|
165
|
+
* 宿主只说「这次放行了,但『不再询问』没存上」,绝不渲成整次审批失败。
|
|
166
|
+
* 🔴 `rule_lane_unavailable` **四种成因共用一格、两种是本卡局限** ⇒ 禁据一帧判断整台部署的车道
|
|
167
|
+
* 在不在(那要看能力位)。
|
|
168
|
+
*/
|
|
169
|
+
export declare function readRulePersistOutcome(ack: unknown): RulePersistOutcome;
|