@sema-agent/server 7.3.0 → 7.5.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 +63 -0
- package/dist/approval-card.d.ts +15 -3
- package/dist/approval-card.js +41 -7
- package/dist/approval-reconciler.d.ts +120 -16
- package/dist/approval-reconciler.js +146 -19
- package/dist/boot/coordinators.js +13 -3
- package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
- package/dist/boot/deferred-sandbox-path-env.js +279 -0
- package/dist/boot/execution-env.js +11 -1
- package/dist/boot/org-memory.d.ts +6 -0
- package/dist/boot/org-memory.js +1 -1
- package/dist/boot/reapers.d.ts +2 -0
- package/dist/boot/reapers.js +11 -4
- package/dist/boot/resolve-spec.d.ts +3 -2
- package/dist/boot/resolve-spec.js +175 -63
- package/dist/boot/runner-deps.d.ts +23 -1
- package/dist/boot/runner-deps.js +8 -11
- package/dist/boot/workflow-orchestration.d.ts +8 -3
- package/dist/boot/workflow-orchestration.js +23 -1
- package/dist/capabilities/center-prompts.js +4 -1
- package/dist/config-center/apply-effective.js +33 -10
- package/dist/config-types.d.ts +32 -9
- package/dist/config.d.ts +6 -1
- package/dist/config.js +65 -12
- package/dist/elicitation.js +5 -1
- package/dist/env-facts.d.ts +3 -1
- package/dist/env-facts.js +3 -1
- package/dist/fleet/fleet-bus.d.ts +6 -1
- package/dist/fleet/fleet-bus.js +25 -3
- package/dist/governance-ask-marks.d.ts +31 -0
- package/dist/governance-ask-marks.js +122 -0
- package/dist/hooks/hook-runner.d.ts +28 -0
- package/dist/hooks/hook-runner.js +180 -24
- package/dist/http/routes/diagnostics.d.ts +84 -0
- package/dist/http/routes/diagnostics.js +145 -0
- package/dist/http/routes/memory-policy.d.ts +2 -1
- package/dist/http/routes/memory-policy.js +77 -13
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/tasks.js +87 -29
- package/dist/http/server.d.ts +10 -0
- package/dist/http/server.js +29 -12
- package/dist/http/wire-types.d.ts +7 -2
- package/dist/main.js +51 -8
- package/dist/observability/fail-open.d.ts +109 -0
- package/dist/observability/fail-open.js +227 -0
- package/dist/observability/prompt-manifest.d.ts +17 -0
- package/dist/observability/prompt-manifest.js +8 -0
- package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
- package/dist/orchestration/workflow-notify-journal.js +137 -32
- package/dist/parked-decide.js +9 -4
- package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
- package/dist/plugins/approval-ask-store-memory.js +3 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
- package/dist/plugins/approval-ask-store-sql.js +9 -2
- package/dist/plugins/background-shell-support.d.ts +1 -1
- package/dist/plugins/background-shell-support.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
- package/dist/plugins/checkpoint-store-sql.js +71 -11
- package/dist/plugins/local-checkpoint-store.d.ts +20 -1
- package/dist/plugins/local-checkpoint-store.js +19 -0
- package/dist/plugins/mailbox-store-sql.d.ts +4 -10
- package/dist/plugins/mailbox-store-sql.js +57 -4
- package/dist/question.d.ts +18 -14
- package/dist/question.js +83 -34
- package/dist/runs.d.ts +8 -0
- package/dist/runs.js +15 -2
- package/dist/runtime-governance.d.ts +18 -0
- package/dist/runtime-governance.js +90 -3
- package/dist/task-settings.d.ts +16 -21
- package/dist/task-settings.js +22 -19
- package/dist/tool-approval.d.ts +33 -6
- package/dist/tool-approval.js +95 -30
- package/dist/trace/core-keyset-guard.d.ts +17 -3
- package/dist/trace/project.d.ts +36 -1
- package/dist/trace/project.js +55 -2
- package/package.json +3 -3
- package/dist/boot/lexical-path-env.d.ts +0 -14
- package/dist/boot/lexical-path-env.js +0 -116
package/dist/question.js
CHANGED
|
@@ -15,29 +15,39 @@
|
|
|
15
15
|
* (the HTTP boundary) and REDACTS secrets from the model-authored QUESTION text before it is persisted/streamed (the
|
|
16
16
|
* same redact-at-write contract as elicitation's `message`/tool outputs — the question is shown to a human, never
|
|
17
17
|
* re-fed to a model, so no `delimitUntrusted` is needed on it). Deployment responsibilities core delegates: THROTTLE
|
|
18
|
-
* (bound concurrency/total/ttl per run — an over-eager agent could flood the human)
|
|
19
|
-
*
|
|
18
|
+
* (bound concurrency/total/ttl per run — an over-eager agent could flood the human).
|
|
19
|
+
*
|
|
20
|
+
* 🔴 VERDICT vs LANDING are separate jobs (#166 裁1). This coordinator is the deployment's HONEST reporter of one fact:
|
|
21
|
+
* "no human answered this". Every arm with no real human answer — no run context, the run aborted, the throttle window
|
|
22
|
+
* is spent, the question cannot be presented, the TTL expired (incl. the run-exit sweep) — returns core's
|
|
23
|
+
* `{kind:"unavailable"}`. It NEVER synthesizes an empty answer set: an empty `{answers:[]}` reads to core as "a human
|
|
24
|
+
* was here and picked nothing", which on a durable deployment consumes the pending approval and hands the model a
|
|
25
|
+
* fabricated human verdict instead of parking the question where an operator can still answer it. Choosing the landing
|
|
26
|
+
* is CORE's job: a durable leg parks a checkpoint (operator answers later via decide), a non-durable leg continues with
|
|
27
|
+
* the `declined_unavailable` synthetic-continuation card (the model is told nobody is reachable and proceeds on its own
|
|
28
|
+
* judgment — a run with no human still never hangs). Only a REAL human answer returns a {@link QuestionAnswer}.
|
|
20
29
|
*
|
|
21
30
|
* ROUTING mirrors elicitation: `onQuestion` lives on the SINGLETON runner's `RunnerDeps` (the request carries no taskId),
|
|
22
31
|
* so the run leg establishes a per-run AsyncLocalStorage context (taskId throttle key, owner respond-gate, `emit`
|
|
23
|
-
* closure). `node:async_hooks` propagates it through every await; no context ⇒
|
|
32
|
+
* closure). `node:async_hooks` propagates it through every await; no context ⇒ unavailable (never a hang).
|
|
24
33
|
*/
|
|
25
34
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
35
|
import { uuidv7 } from "@sema-agent/core";
|
|
27
36
|
import { redactDeep } from "./trace/redact.js";
|
|
37
|
+
import { recordFailOpen } from "./observability/fail-open.js";
|
|
28
38
|
/** Size bound on the model-authored questions payload before it is persisted to the durable log + streamed (parity
|
|
29
39
|
* with elicitation's MAX_ELICIT_* — an over-large questions blob amplifies into the socket/log). Over the cap ⇒ the
|
|
30
|
-
* questions are dropped and the ask
|
|
40
|
+
* questions are dropped and the ask reports unavailable (a question the human can't see can't be answered). */
|
|
31
41
|
const MAX_QUESTIONS_BYTES = 16384;
|
|
32
42
|
export const DEFAULT_QUESTION_THROTTLE = {
|
|
33
43
|
maxConcurrentPerRun: 2,
|
|
34
44
|
maxTotalPerRun: 20,
|
|
35
45
|
ttlMs: 5 * 60_000,
|
|
36
46
|
};
|
|
37
|
-
/** The
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
const
|
|
47
|
+
/** The honest "nobody answered this" outcome. Frozen + shared: it is pure data core reads through
|
|
48
|
+
* `classifyQuestionOutcome`, and it must never be confused with an empty ANSWER (which claims a human chose nothing
|
|
49
|
+
* and, on a durable leg, spends the approval that park would have preserved). */
|
|
50
|
+
const UNAVAILABLE = Object.freeze({ kind: "unavailable" });
|
|
41
51
|
/** Validate the respond body = core's {@link QuestionAnswer}. `selected`/`note` are OPAQUE passthrough (core fences
|
|
42
52
|
* them: `selected ⊆ options`, `note` untrusted-fenced) — we validate only the outer SHAPE (answers[] of {header,
|
|
43
53
|
* selected:string[], note?}), never the option semantics. */
|
|
@@ -65,7 +75,7 @@ export function parseQuestionResponse(body) {
|
|
|
65
75
|
}
|
|
66
76
|
/** redactDeep + size-bound the UNTRUSTED model-authored questions before they are persisted/streamed (a question's
|
|
67
77
|
* free-text — question/header/option label+description — can echo a secret from the model's context). Over the byte
|
|
68
|
-
* cap OR unserializable ⇒ undefined (drop; the ask
|
|
78
|
+
* cap OR unserializable ⇒ undefined (drop; the ask reports unavailable). */
|
|
69
79
|
function boundQuestions(questions) {
|
|
70
80
|
const redacted = redactDeep(questions);
|
|
71
81
|
try {
|
|
@@ -81,7 +91,7 @@ function boundQuestions(questions) {
|
|
|
81
91
|
* Coordinates AskUserQuestion HITL for the singleton runner. Process-local + same-replica (the pending map is in memory,
|
|
82
92
|
* like ElicitationCoordinator / steerableRuns): a respond that lands on another replica finds nothing → 404. Present
|
|
83
93
|
* (passed into `RunnerDeps.onQuestion` + the respond route) ONLY when `ASK_QUESTION_ENABLED` — absent ⇒ core mounts the
|
|
84
|
-
* AskUserQuestion tool with
|
|
94
|
+
* AskUserQuestion tool with no seam at all (`seam_absent` continuation; the model just can't get a live answer).
|
|
85
95
|
*/
|
|
86
96
|
export class QuestionCoordinator {
|
|
87
97
|
als = new AsyncLocalStorage();
|
|
@@ -92,7 +102,7 @@ export class QuestionCoordinator {
|
|
|
92
102
|
this.throttle = throttle;
|
|
93
103
|
}
|
|
94
104
|
/** Run `fn` with the per-run question context ambient. On exit, release any still-pending question for this run (a
|
|
95
|
-
* live-only question cannot outlive its leg →
|
|
105
|
+
* live-only question cannot outlive its leg → unavailable) and drop the run's counters (no leak). */
|
|
96
106
|
runWithContext(ctx, fn) {
|
|
97
107
|
return this.als.run(ctx, async () => {
|
|
98
108
|
try {
|
|
@@ -101,31 +111,34 @@ export class QuestionCoordinator {
|
|
|
101
111
|
finally {
|
|
102
112
|
for (const [, p] of [...this.pending]) {
|
|
103
113
|
if (p.taskId === ctx.taskId)
|
|
104
|
-
p.settle(
|
|
114
|
+
p.settle(UNAVAILABLE, "unanswered");
|
|
105
115
|
}
|
|
106
116
|
this.counters.delete(ctx.taskId);
|
|
107
117
|
}
|
|
108
118
|
});
|
|
109
119
|
}
|
|
110
|
-
/** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved
|
|
111
|
-
*
|
|
112
|
-
*
|
|
120
|
+
/** `RunnerDeps.onQuestion`. core calls this when the agent's AskUserQuestion tool fires; the resolved outcome is what
|
|
121
|
+
* core acts on — a {@link QuestionAnswer} ONLY when a human really answered, otherwise `{kind:"unavailable"}` (core
|
|
122
|
+
* then parks on a durable leg / continues with the `declined_unavailable` card on a non-durable one; either way the
|
|
123
|
+
* run never hangs). Arrow property so it can be passed as `onQuestion: coordinator.question` with `this` bound. */
|
|
113
124
|
question = async (req, signal) => {
|
|
114
125
|
const ctx = this.als.getStore();
|
|
115
126
|
// No run context (a leg not wrapped by runWithContext — synchronous /v1/tasks, verify/cascade, or a leader sub-task
|
|
116
|
-
// with no live stream) ⇒
|
|
127
|
+
// with no live stream) ⇒ nobody to ask. Sub-tasks run in the parent's ALS frame → a child's question inherits the
|
|
117
128
|
// top-level run's ctx (one human, one stream, one throttle budget), same as elicitation.
|
|
118
129
|
if (!ctx)
|
|
119
|
-
return
|
|
130
|
+
return UNAVAILABLE;
|
|
131
|
+
// Aborted (call signal or the run's own): whatever comes back has no consumer, so claiming a human answered is a lie.
|
|
120
132
|
if (signal?.aborted || ctx.abortSignal?.aborted)
|
|
121
|
-
return
|
|
122
|
-
// THROTTLE (service's job — core imposes none): bound concurrency + total. Breach ⇒
|
|
133
|
+
return UNAVAILABLE;
|
|
134
|
+
// THROTTLE (service's job — core imposes none): bound concurrency + total. Breach ⇒ unavailable, never a denial and
|
|
135
|
+
// never a fabricated answer — a spent window is a "not now", which is exactly what park/continue is for.
|
|
123
136
|
const rc = this.countersFor(ctx.taskId);
|
|
124
137
|
if (rc.inflight >= this.throttle.maxConcurrentPerRun || rc.total >= this.throttle.maxTotalPerRun)
|
|
125
|
-
return
|
|
138
|
+
return UNAVAILABLE;
|
|
126
139
|
const questions = boundQuestions(req.questions);
|
|
127
140
|
if (!questions)
|
|
128
|
-
return
|
|
141
|
+
return UNAVAILABLE; // over-cap / unserializable → can't show it → nobody can answer it
|
|
129
142
|
const id = uuidv7();
|
|
130
143
|
rc.inflight += 1; // reserve the concurrency slot; total charged only on a successful emit
|
|
131
144
|
let done = false;
|
|
@@ -147,8 +160,10 @@ export class QuestionCoordinator {
|
|
|
147
160
|
resolveAnswer(a);
|
|
148
161
|
};
|
|
149
162
|
let lastOutcome = "unanswered";
|
|
150
|
-
const onAbort = () => settle(
|
|
151
|
-
|
|
163
|
+
const onAbort = () => settle(UNAVAILABLE, "unanswered");
|
|
164
|
+
// TTL: the human walked away. Release as unavailable — core decides whether that parks (durable, operator can still
|
|
165
|
+
// answer) or continues (non-durable); holding core past the window is not one of the choices.
|
|
166
|
+
timer = setTimeout(() => settle(UNAVAILABLE, "unanswered"), this.throttle.ttlMs);
|
|
152
167
|
timer.unref?.();
|
|
153
168
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
154
169
|
ctx.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -156,20 +171,53 @@ export class QuestionCoordinator {
|
|
|
156
171
|
this.pending.set(id, { settle, owner: ctx.owner, taskId: ctx.taskId });
|
|
157
172
|
// Emit the OPEN frame AWAITED so its durable append is ORDERED ahead of the answer/completion/terminal appends (a
|
|
158
173
|
// fire-and-forget append can commit out of seq order and be skipped by the monotonic events-tail cursor). A FAILED
|
|
159
|
-
// emit
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
// emit settles unavailable (nobody can answer a frame that never arrived) and must NOT burn the per-run total.
|
|
175
|
+
// 🔴 RACED against the settlement (codex 复审 R2-high): a STALLED delivery (a durable append that never settles, a
|
|
176
|
+
// half-open socket) is a third shape next to ok/threw, and a bare `await ctx.emit` on it outlives TTL *and* abort —
|
|
177
|
+
// settle would resolve `answerP` while this frame is still parked on the emit, so `question()` never returns, core's
|
|
178
|
+
// callTool never unblocks and the leg cannot even reach its own cleanup. "The window always releases" has to hold
|
|
179
|
+
// for the stalled shape too, so the await ends at whichever comes first. Ordering is unchanged on the normal path
|
|
180
|
+
// (the settlement is still pending there, so the emit is still fully awaited).
|
|
181
|
+
// The loser of the race keeps its own handlers — an emit that rejects AFTER the race must not surface as an
|
|
182
|
+
// unhandled rejection.
|
|
183
|
+
const emitted = (async () => ctx.emit({ type: "question", questionId: id, questions, serverNowMs: Date.now() }))()
|
|
184
|
+
.then(() => "ok", () => "failed");
|
|
185
|
+
const delivery = await Promise.race([emitted, answerP.then(() => "stalled")]);
|
|
186
|
+
if (delivery === "ok") {
|
|
162
187
|
rc.total += 1;
|
|
163
188
|
}
|
|
164
|
-
|
|
165
|
-
|
|
189
|
+
else if (delivery === "failed") {
|
|
190
|
+
// #157(codex 复审 MEDIUM,2026-08-07):投递失败 ⇒ 人在环这道门被**一次故障**跳过,模型自己往下判。
|
|
191
|
+
// 「绝不把 run 挂死」是刻意的产品姿态,所以方向不改;但它是 P-DEBT 不是合法兜底——债要在遥测里显形。
|
|
192
|
+
// #166 后本臂结算成 unavailable(而非空答):留痕语义不变(一次投递故障仍可能让人在环被跳过——非
|
|
193
|
+
// durable 形 core 合成续跑),durable 形则改由 core park,债的方向与本记账口径一致。
|
|
194
|
+
recordFailOpen("server.question.open-frame-undelivered", `question=${id}`);
|
|
195
|
+
settle(UNAVAILABLE, "unanswered");
|
|
166
196
|
}
|
|
197
|
+
// delivery==="stalled":窗已由 TTL/abort/清扫结算,这一问就此让位;账在下面那个迟到臂里补(投递还没
|
|
198
|
+
// 判出成败,现在既不能计费也不能记债)。
|
|
167
199
|
const answer = await answerP;
|
|
168
200
|
// Completion breadcrumb (dialog dismiss) — FIRE-AND-FORGET so a slow durable append can never delay returning the
|
|
169
|
-
// answer to core. Best-effort
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
// answer to core. Best-effort (the shell already has the answer via respond), but CHAINED BEHIND the open frame and
|
|
202
|
+
// BRANCHED on how that frame ended (codex 复审 R3):
|
|
203
|
+
// · 送达(含**迟到**送达)⇒ 人真看见了这张卡:补烧总量额度(否则「反复悬挂再落地」是一条绕过刷屏帽的
|
|
204
|
+
// 路子),再发消卡帧把它消解掉;
|
|
205
|
+
// · 投递失败(含**迟到**失败)⇒ 没有卡可消解,发消卡帧只会在流里留一条无主帧;此时补记那条 P-DEBT
|
|
206
|
+
// 留痕(同步臂已经记过的不会重复:那条腿的 delivery 不是 stalled)。
|
|
207
|
+
void emitted
|
|
208
|
+
.then((result) => {
|
|
209
|
+
if (result === "failed") {
|
|
210
|
+
if (delivery === "stalled")
|
|
211
|
+
recordFailOpen("server.question.open-frame-undelivered", `question=${id}`);
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
if (delivery === "stalled")
|
|
215
|
+
rc.total += 1;
|
|
216
|
+
return ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome, serverNowMs: Date.now() });
|
|
217
|
+
})
|
|
218
|
+
// #157:F 类兜底(丢的是收尾渲染,不是答案)——合法,但必须留痕,否则"面包屑成片消失"只能靠
|
|
219
|
+
// 用户报"对话框不消解"发现。
|
|
220
|
+
.catch(() => recordFailOpen("server.question.complete-breadcrumb-dropped", `question=${id}`));
|
|
173
221
|
return answer;
|
|
174
222
|
};
|
|
175
223
|
/** `POST /v1/questions/:id/respond` — resolve a parked question with the shell's answer. Owner-gated with a 404 (no
|
|
@@ -197,9 +245,10 @@ export class QuestionCoordinator {
|
|
|
197
245
|
* resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
|
|
198
246
|
* ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
|
|
199
247
|
* 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
|
|
200
|
-
* 只判 ALS 会把一条谁也收不到的问题判成 allow
|
|
248
|
+
* 只判 ALS 会把一条谁也收不到的问题判成 allow——挂满 ttl 后以 unavailable 结算,非 durable 形 core
|
|
249
|
+
* 就此合成续跑,活人再没机会答;而 park 才是它该走的腿。
|
|
201
250
|
* 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
|
|
202
|
-
* fail-closed 到 park(park
|
|
251
|
+
* fail-closed 到 park(park 可由运维补答,续跑掉的问题不可回收)。 */
|
|
203
252
|
hasLiveContext() {
|
|
204
253
|
const ctx = this.als.getStore();
|
|
205
254
|
if (ctx === undefined)
|
package/dist/runs.d.ts
CHANGED
|
@@ -80,6 +80,14 @@ export declare function taskHandleStop(registry: typeof defaultTaskRegistry, han
|
|
|
80
80
|
content: string;
|
|
81
81
|
details: unknown;
|
|
82
82
|
}>;
|
|
83
|
+
/**
|
|
84
|
+
* Attribute this signal's abort as a USER stop for the owner's task-scoped children.
|
|
85
|
+
*
|
|
86
|
+
* 🔴 已经 abort 的信号在这里**立刻**记账,不是静默无操作(#168 件3 的配套)。调用点在 #168 件3 之后
|
|
87
|
+
* 一律挪到了「run 认领成功之后」—— 认领是一次 await,断连完全可能落在它之前。`addEventListener` 对一个
|
|
88
|
+
* 已 fire 的信号永不回调,那样一次真实的人为断连会被吞成「无归因」(子代 settle 时记成 system)。
|
|
89
|
+
* 语义上这两种时序是同一件事:本请求赢下了这条 session,而这条 session 的连接是被人断掉的。
|
|
90
|
+
*/
|
|
83
91
|
export declare function markChildrenStoppedByUserOnAbort(signal: AbortSignal, taskId: string, principal: string | null | undefined): void;
|
|
84
92
|
/**
|
|
85
93
|
* A session CAS conflict is swallowed by core into a failed `TaskResult` (it is not thrown from
|
package/dist/runs.js
CHANGED
|
@@ -111,15 +111,28 @@ export async function taskHandleStop(registry, handle, access, agentStore) {
|
|
|
111
111
|
registry.markStopSource(handle, "user");
|
|
112
112
|
return registry.stopTask(handle, access);
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Attribute this signal's abort as a USER stop for the owner's task-scoped children.
|
|
116
|
+
*
|
|
117
|
+
* 🔴 已经 abort 的信号在这里**立刻**记账,不是静默无操作(#168 件3 的配套)。调用点在 #168 件3 之后
|
|
118
|
+
* 一律挪到了「run 认领成功之后」—— 认领是一次 await,断连完全可能落在它之前。`addEventListener` 对一个
|
|
119
|
+
* 已 fire 的信号永不回调,那样一次真实的人为断连会被吞成「无归因」(子代 settle 时记成 system)。
|
|
120
|
+
* 语义上这两种时序是同一件事:本请求赢下了这条 session,而这条 session 的连接是被人断掉的。
|
|
121
|
+
*/
|
|
114
122
|
export function markChildrenStoppedByUserOnAbort(signal, taskId, principal) {
|
|
115
|
-
|
|
123
|
+
const mark = () => {
|
|
116
124
|
try {
|
|
117
125
|
defaultTaskRegistry.markStopSourceForOwner({ owner: taskId, scope: principal ?? "default" }, "user", { skipSessionScoped: true });
|
|
118
126
|
}
|
|
119
127
|
catch {
|
|
120
128
|
/* attribution is advisory — the cancel itself must never fail on it */
|
|
121
129
|
}
|
|
122
|
-
}
|
|
130
|
+
};
|
|
131
|
+
if (signal.aborted) {
|
|
132
|
+
mark();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
signal.addEventListener("abort", mark, { once: true });
|
|
123
136
|
}
|
|
124
137
|
/**
|
|
125
138
|
* A session CAS conflict is swallowed by core into a failed `TaskResult` (it is not thrown from
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
|
|
25
25
|
*/
|
|
26
26
|
import { type TaskSpec, type ToolPolicy } from "@sema-agent/core";
|
|
27
|
+
import { type GovernanceAskMarks } from "./governance-ask-marks.js";
|
|
27
28
|
/** A per-command governance rule (registry `runtime.commandPolicy[]`). */
|
|
28
29
|
export type CommandRule = {
|
|
29
30
|
command: string;
|
|
@@ -106,10 +107,27 @@ export declare function autonomyOverrides(autonomy: Autonomy | undefined): Parti
|
|
|
106
107
|
* ⇒ 旋钮静默失效(clay 实机 Monitor 案「门根本没铸」);bypassPermissions 一句话掀掉部署闸;非 host lane
|
|
107
108
|
* fsWriteGate wiring 不建 ⇒ 沙箱 lane 全失效。governance 层与 autonomy 同拍施加后三者全闭。rank 合成:
|
|
108
109
|
* autonomy 派生值与旋钮取大(ask→"always" 压过 "classify"),再对 base 只升不降(防 tightenTaskSpec throw)。
|
|
110
|
+
*
|
|
111
|
+
* #177([2951] issue #29,**邻仓 test AI 的 46 格真机矩阵**实证——不是本仓的钉,别到本仓文件里数 46;
|
|
112
|
+
* 本仓的类级门是 test/operator-knob-client-posture-matrix.test.ts 的 旋钮×表态×lane 矩阵):
|
|
113
|
+
* `SENSITIVE_WRITE_PATTERNS` 是 #153 那次搬家漏下的兄弟旋钮——同一个病灶原样复发在 sensitive-path
|
|
114
|
+
* DENY 腿上(唯一合成点在 deriveSettingsPolicy 的 fsWriteGate 闭包,bypassPermissions/键缺席/settings
|
|
115
|
+
* 整缺席三形整条腿不建)。同方修:调用方(resolve-spec)把 operator 守卫集预铸成 `sensitivePathPolicy`
|
|
116
|
+
* 从这里**无条件施加**——它只在守卫段上产 deny(core 语义 deny/"safety"),对其余目标返回的是
|
|
117
|
+
* `action:"allow"`(**不是**「无意见」这种第三态:core 的 ToolPolicy 没有弃权值。它在
|
|
118
|
+
* `combinePolicies` 的 deny/ask 优先折叠里不构成一票,所以在**折叠语境**下等价于弃权;若日后有人把
|
|
119
|
+
* 这条策略单独当成唯一 policy 用,那个 allow 就是真放行——别照抄这句话去别的语境)。于是对 bypass
|
|
120
|
+
* 只挡守卫段、不给普通目标加 ask 门([820] 表第四行「bypass=不加门非开门」保持逐字)。策略在这里
|
|
121
|
+
* 只组合不构造:裁决 env 的 lane 分形(host 真 fs / 沙箱 deferred 代理)是 resolve-spec 的属主知识,
|
|
122
|
+
* governance 层不重复它。
|
|
109
123
|
*/
|
|
110
124
|
export declare function applyRuntimeGovernance(base: TaskSpec, governance: {
|
|
111
125
|
autonomy?: Autonomy;
|
|
112
126
|
commandPolicy?: CommandRule[];
|
|
113
127
|
manualModeShellGate?: "always" | "classify";
|
|
128
|
+
sensitivePathPolicy?: ToolPolicy;
|
|
129
|
+
/** [2942]/[2943] `governanceForced` 的标记表(缺省 = **当前 run 腿的 ALS 表**,见
|
|
130
|
+
* `governance-ask-marks.ts` 顶注「作用域」段)。注入口只为测试与将来的多实例形。 */
|
|
131
|
+
askMarks?: GovernanceAskMarks;
|
|
114
132
|
}): TaskSpec;
|
|
115
133
|
//# sourceMappingURL=runtime-governance.d.ts.map
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* name). Real isolation is the `executionEnv` sandbox. Same caveat core documents on `createCoarseCommandNamePolicy`.
|
|
25
25
|
*/
|
|
26
26
|
import { combinePolicies, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
|
|
27
|
+
import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
|
|
27
28
|
/** True when `name` names a DELEGATION tool — the subagent tool(CC-187 canonical `"Agent"`,core
|
|
28
29
|
* `DEFAULT_SUBAGENT_TOOL_NAME`)。5.0.0 RB-476:折叠面退役,RAW 比对 core 单源常量——旧名("Task")
|
|
29
30
|
* 在 core roster 层响亮 miss,不再需要归一;单源常量本身就防了当年 1.202 改名时裸字符串失配的
|
|
@@ -178,7 +179,14 @@ export function compileCommandPolicy(rules) {
|
|
|
178
179
|
policies.push(createCoarseCommandNamePolicy({ allow, deny, defaultAction: "ask" }));
|
|
179
180
|
}
|
|
180
181
|
else {
|
|
181
|
-
// Blocklist mode: only `deny` blocks; per-command `ask` via the ask-list; unlisted
|
|
182
|
+
// Blocklist mode: only `deny` blocks; per-command `ask` via the ask-list; an unlisted **simple** command
|
|
183
|
+
// passes through.
|
|
184
|
+
// ⚠️ #184([3038]/[3047] 双探针实证):「unlisted pass through」只对**单纯命令**成立。带 shell 运算符的
|
|
185
|
+
// 复合命令(`a && b`、管道、`;`)`parseLeadingCommandName` 判 reject,而 `createCoarseCommandNamePolicy`
|
|
186
|
+
// 的 `defaultAction` **缺省就是 `"ask"`**(core tool-policy.js:`opts.defaultAction ?? "ask"`)—— 于是它们
|
|
187
|
+
// 在 deny 门上折 **ask**,不是放行(无 command 串的调用同理)。这与 allowlist 模式那句「fall to
|
|
188
|
+
// defaultAction」是同一个兜底,只是这里没显式传参所以容易读漏。上方 docblock 的「Un-parseable bypass
|
|
189
|
+
// commands fail toward `ask` in both the deny gate and the ask-list」说的就是这一条。零行为码改动。
|
|
182
190
|
// (NOTE: never pass `allow: []` to the coarse helper — an empty array is truthy there and would turn it
|
|
183
191
|
// into an allowlist that asks on EVERYTHING. Omit allow entirely instead.)
|
|
184
192
|
if (deny.length > 0)
|
|
@@ -211,6 +219,56 @@ export function autonomyOverrides(autonomy) {
|
|
|
211
219
|
return {};
|
|
212
220
|
}
|
|
213
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* [2942]/[2943] `governanceForced` 的**写侧** —— 把内层策略(治理层自己合成的那些)产的 `ask` 记进标记表。
|
|
224
|
+
*
|
|
225
|
+
* 决议本身**逐字透传**:本包装器只观察,不改 action / message / updatedInput / decisionReason,也不吞异常
|
|
226
|
+
* (内层抛就照抛,fail-closed 的属主仍是内层)。同步内层保持同步返回(不无谓地把一条同步策略异步化)。
|
|
227
|
+
*
|
|
228
|
+
* 为什么标在这一层而不是让 `decisionReason` 上 wire:core 的 ask 铸造点不透传 `PermissionResult`
|
|
229
|
+
* (侦察结论逐字见 `governance-ask-marks.ts` 顶注),这条缝在当前 core 上根本不存在。
|
|
230
|
+
*/
|
|
231
|
+
function createGovernanceAskMarkingPolicy(inner, marksOf) {
|
|
232
|
+
// 🔴 codex 交叉复审 round2 [medium](验真):**如实覆写**,不是只加不减 —— core 的
|
|
233
|
+
// `recheckApprovedEdit` 拿同一个 `toolCallId` 重跑策略链(人编辑 args 之后),「原始 args 治理层要问 /
|
|
234
|
+
// 编辑后治理层放行、由别的策略提第二问」会让第二张卡挂陈旧治理标。理由全文见 GovernanceAskMarks.setMarked。
|
|
235
|
+
const note = (d, req) => {
|
|
236
|
+
// 表在**裁决那一刻**解析(不是构造时):策略闭包铸于 resolveSpec,真正的作用域要到 run 腿跑起来才在。
|
|
237
|
+
marksOf()?.setMarked(req.toolCallId, d.action === "ask");
|
|
238
|
+
return d;
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
check(req, signal) {
|
|
242
|
+
const decision = inner.check(req, signal);
|
|
243
|
+
return decision instanceof Promise ? decision.then((d) => note(d, req)) : note(decision, req);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* [2942]/[2943] shellGate 腿的写侧 —— **纯观察器**(恒 `allow` = `combinePolicies` 折叠里的弃权,见
|
|
249
|
+
* `applyRuntimeGovernance` 顶注对 sensitivePathPolicy 那段 allow 语义的同款说明)。
|
|
250
|
+
*
|
|
251
|
+
* 只在治理层把 shellGate 抬到 **`"always"`** 时装:该档下 core 对**每一次** shell 调用都要人批
|
|
252
|
+
* (`prepare-task.js` 把 Bash/Monitor 的 irreversibility tier 置 `"always"`),所以「这是一次 shell 调用」
|
|
253
|
+
* 与「这只 ask 是治理层门出来的」等价——推断是紧的,零错标。
|
|
254
|
+
*
|
|
255
|
+
* 🔴 `"classify"` 档**故意不装**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能来自分类器
|
|
256
|
+
* (治理),也可能来自 `APPROVAL_REQUIRE` 这类别的门(非治理),在 `AskRequest` 上无从分辨。按本信号
|
|
257
|
+
* 「只做正向断言」的语义,分不清就**让键缺席**,不去猜(`governance-ask-marks.ts` 语义 1)。
|
|
258
|
+
*/
|
|
259
|
+
function createGovernanceShellGateMarkPolicy(marksOf, tools = COARSE_SHELL_TOOLS) {
|
|
260
|
+
const shellTools = new Set(tools);
|
|
261
|
+
return {
|
|
262
|
+
check(req) {
|
|
263
|
+
// 只对 shell 工具**置位**,非 shell 一格不碰(那格的属主是上面的 ask 观察器)。本策略在合成里
|
|
264
|
+
// 恒排在 ask 观察器**之后**(见 applyRuntimeGovernance 的 combinePolicies 顺序):`always` 档下
|
|
265
|
+
// 每一次 shell 调用都被治理层门住,所以它对 shell 那一格的裁定压过「治理策略这次没产 ask」。
|
|
266
|
+
if (shellTools.has(req.toolName))
|
|
267
|
+
marksOf()?.setMarked(req.toolCallId, true);
|
|
268
|
+
return { action: "allow" };
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
214
272
|
/** core `tighten-task-spec` 的 SHELL_GATE_RANK 镜像(单一属主:#153 搬家后本文件是 server 侧唯一副本;
|
|
215
273
|
* task-settings 的旧副本随 settings 折叠臂一并拆除)。tightenTaskSpec 的契约是「override 自身必须收紧」
|
|
216
274
|
* 而非「取两者较严」——低于 base 的 override 直接 throw,所以施加前要按 rank 只升不降。 */
|
|
@@ -229,12 +287,34 @@ const SHELL_GATE_RANK = { off: 0, classify: 1, always: 2 };
|
|
|
229
287
|
* ⇒ 旋钮静默失效(clay 实机 Monitor 案「门根本没铸」);bypassPermissions 一句话掀掉部署闸;非 host lane
|
|
230
288
|
* fsWriteGate wiring 不建 ⇒ 沙箱 lane 全失效。governance 层与 autonomy 同拍施加后三者全闭。rank 合成:
|
|
231
289
|
* autonomy 派生值与旋钮取大(ask→"always" 压过 "classify"),再对 base 只升不降(防 tightenTaskSpec throw)。
|
|
290
|
+
*
|
|
291
|
+
* #177([2951] issue #29,**邻仓 test AI 的 46 格真机矩阵**实证——不是本仓的钉,别到本仓文件里数 46;
|
|
292
|
+
* 本仓的类级门是 test/operator-knob-client-posture-matrix.test.ts 的 旋钮×表态×lane 矩阵):
|
|
293
|
+
* `SENSITIVE_WRITE_PATTERNS` 是 #153 那次搬家漏下的兄弟旋钮——同一个病灶原样复发在 sensitive-path
|
|
294
|
+
* DENY 腿上(唯一合成点在 deriveSettingsPolicy 的 fsWriteGate 闭包,bypassPermissions/键缺席/settings
|
|
295
|
+
* 整缺席三形整条腿不建)。同方修:调用方(resolve-spec)把 operator 守卫集预铸成 `sensitivePathPolicy`
|
|
296
|
+
* 从这里**无条件施加**——它只在守卫段上产 deny(core 语义 deny/"safety"),对其余目标返回的是
|
|
297
|
+
* `action:"allow"`(**不是**「无意见」这种第三态:core 的 ToolPolicy 没有弃权值。它在
|
|
298
|
+
* `combinePolicies` 的 deny/ask 优先折叠里不构成一票,所以在**折叠语境**下等价于弃权;若日后有人把
|
|
299
|
+
* 这条策略单独当成唯一 policy 用,那个 allow 就是真放行——别照抄这句话去别的语境)。于是对 bypass
|
|
300
|
+
* 只挡守卫段、不给普通目标加 ask 门([820] 表第四行「bypass=不加门非开门」保持逐字)。策略在这里
|
|
301
|
+
* 只组合不构造:裁决 env 的 lane 分形(host 真 fs / 沙箱 deferred 代理)是 resolve-spec 的属主知识,
|
|
302
|
+
* governance 层不重复它。
|
|
232
303
|
*/
|
|
233
304
|
export function applyRuntimeGovernance(base, governance) {
|
|
305
|
+
// 显式注入(测试/将来的多实例形)优先;否则**每次裁决时**取当前 run 腿的 ALS 表(顶注「作用域」段)。
|
|
306
|
+
const marksOf = () => governance.askMarks ?? currentGovernanceAskMarks();
|
|
234
307
|
const overrides = { ...autonomyOverrides(governance.autonomy) };
|
|
235
308
|
const coarse = compileCommandPolicy(governance.commandPolicy);
|
|
236
|
-
|
|
237
|
-
|
|
309
|
+
const governancePolicies = [coarse, governance.sensitivePathPolicy].filter((p) => p !== undefined);
|
|
310
|
+
// [2942]/[2943]:治理层自己产的 `ask` 打标 —— 包在**合成之后**,于是 commandPolicy 的两模式
|
|
311
|
+
// (allowlist 的 defaultAction:"ask" / blocklist 的 ask-list)与 sensitivePathPolicy 走同一个铸点,
|
|
312
|
+
// 不会漏掉将来加进这个数组的第四条腿。
|
|
313
|
+
const governancePolicy = governancePolicies.length > 0
|
|
314
|
+
? createGovernanceAskMarkingPolicy(governancePolicies.length === 1 ? governancePolicies[0] : combinePolicies(...governancePolicies), marksOf)
|
|
315
|
+
: undefined;
|
|
316
|
+
if (governancePolicy !== undefined)
|
|
317
|
+
overrides.toolPolicy = governancePolicy;
|
|
238
318
|
if (governance.manualModeShellGate !== undefined) {
|
|
239
319
|
const candidate = overrides.shellGate !== undefined && SHELL_GATE_RANK[overrides.shellGate] >= SHELL_GATE_RANK[governance.manualModeShellGate]
|
|
240
320
|
? overrides.shellGate
|
|
@@ -249,6 +329,13 @@ export function applyRuntimeGovernance(base, governance) {
|
|
|
249
329
|
else
|
|
250
330
|
delete overrides.shellGate;
|
|
251
331
|
}
|
|
332
|
+
// [2942]/[2943] shellGate 腿:治理层**自己**把门抬到 `"always"` 时,挂上纯观察器给 shell 调用打标
|
|
333
|
+
// (理由与 `"classify"` 为何不装,见 createGovernanceShellGateMarkPolicy 顶注)。判据取
|
|
334
|
+
// `overrides.shellGate` 而不是有效档 —— base 已经更严时上面那支会 `delete`,那时门不是治理层给的。
|
|
335
|
+
if (overrides.shellGate === "always") {
|
|
336
|
+
const shellObserver = createGovernanceShellGateMarkPolicy(marksOf);
|
|
337
|
+
overrides.toolPolicy = governancePolicy === undefined ? shellObserver : combinePolicies(governancePolicy, shellObserver);
|
|
338
|
+
}
|
|
252
339
|
if (overrides.toolPolicy === undefined && overrides.handsReadOnly === undefined && overrides.shellGate === undefined) {
|
|
253
340
|
return base; // nothing to govern → avoid a needless tightenTaskSpec pass
|
|
254
341
|
}
|
package/dist/task-settings.d.ts
CHANGED
|
@@ -149,7 +149,7 @@ export declare function providerDropsAppend(provider: PromptProvider | undefined
|
|
|
149
149
|
export declare function acceptAppendSystemPrompt(v: unknown, warn?: (detail: string) => void, packDropsAppend?: boolean): string | undefined;
|
|
150
150
|
/** [1248]②/codex F2 — what the workflow ask leg needs from the deployment, on EVERY lane. It is name-keyed and
|
|
151
151
|
* fs-independent, so it never had the lane split {@link FsWriteGateWiring} carried (that split is itself gone
|
|
152
|
-
* since #156 — the sandbox lanes
|
|
152
|
+
* since #156 — the sandbox lanes wire a gate of their own shape instead of no gate). */
|
|
153
153
|
export interface WorkflowGateWiring {
|
|
154
154
|
/** The session "don't ask again" probe (approvalExemptionStore.has, canonical toolName key) — same store and
|
|
155
155
|
* key space as the fs-write gate's probe; one remember="session" grant serves both. */
|
|
@@ -162,22 +162,23 @@ export interface WorkflowGateWiring {
|
|
|
162
162
|
* (real `exists`/`canonicalPath`/`readLink` fs access — read in dist, fs-write-gate-policy.js), so on a
|
|
163
163
|
* HOST-SEMANTICS lane (`REMOTE_EXEC` unset or "host") the env MUST be the fs the hand tools write.
|
|
164
164
|
*
|
|
165
|
-
* #156 — the sandbox lanes (e2b/k8s/ssh/adb/local-docker)
|
|
165
|
+
* #156/#165 — the sandbox lanes (e2b/k8s/ssh/adb/local-docker) wire a gate TOO, but a different-shaped one: the
|
|
166
166
|
* tools run OFF this box and the per-task sandbox env is minted inside core (executionEnvFactory) AFTER spec
|
|
167
167
|
* build, so a worker-local env would adjudicate against the WRONG fs (a wrong ALLOW is worse than no gate — that
|
|
168
|
-
* is why [816] left those lanes gate-less).
|
|
169
|
-
* (`src/boot/
|
|
170
|
-
*
|
|
171
|
-
*
|
|
168
|
+
* is why [816] left those lanes gate-less). They supply a `DeferredSandboxPathEnv`
|
|
169
|
+
* (`src/boot/deferred-sandbox-path-env.ts`) instead: a proxy that forwards the fs READ primitives to the task's
|
|
170
|
+
* REAL sandbox env at adjudication time (a per-session slot the factory decorator fills), keeping only
|
|
171
|
+
* `absolutePath` lexical so no relative path is resolved against the wrong base. Empty slot ⇒ fail-closed. */
|
|
172
172
|
export interface FsWriteGateWiring {
|
|
173
|
-
/** The env the gate canonicalizes against — the worker host env on the host lane, the
|
|
174
|
-
* `
|
|
173
|
+
/** The env the gate canonicalizes against — the worker host env on the host lane, the
|
|
174
|
+
* `DeferredSandboxPathEnv` proxy over the task's real sandbox env on the sandbox lanes (#165). */
|
|
175
175
|
env: ExecutionEnv;
|
|
176
176
|
/** The task's working directory: the factory's `rootPath` (relative-path base) AND the acceptEdits accept domain.
|
|
177
|
-
* ABSENT on the sandbox lanes (#156): the sandbox cwd is
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
177
|
+
* ABSENT on the sandbox lanes (#156/#165): the sandbox cwd is the ENGINE's tracked cwd, not the env's own, so
|
|
178
|
+
* neither spec time nor the env proxy can supply the right base — inventing one would mint a bogus auto-allow
|
|
179
|
+
* domain. With it absent the gate gets no `rootPath` and no `acceptDirs`, so relative paths fail to resolve
|
|
180
|
+
* (⇒ ask) and `acceptEdits` degrades to the `default` arm — the same fail-safe direction as the host lane's
|
|
181
|
+
* never-created sentinel dir (resolve-spec 修5). */
|
|
181
182
|
cwd?: string;
|
|
182
183
|
/** The session scratchpad dir (envFacts.scratchpadDir, [820]③) — writes there are always auto-allowed. */
|
|
183
184
|
scratchpadDir?: string;
|
|
@@ -191,16 +192,10 @@ export interface FsWriteGateWiring {
|
|
|
191
192
|
* allow with decisionReason "rule", a THROWN probe ⇒ not exempt = fail-closed to ask). Same canonical
|
|
192
193
|
* toolName key space as the ask-policy layer's exemption probe — ONE grant serves both layers; the gate-level
|
|
193
194
|
* check is what makes "allow all session" bite SAME-TURN for the parent and inherited child tasks (the ask
|
|
194
|
-
* path is never re-entered). (codex M1
|
|
195
|
-
*
|
|
196
|
-
*
|
|
195
|
+
* path is never re-entered). (codex M1 的边界注记:sensitive-path DENY 腿与本探针的「豁免越不过 deny」
|
|
196
|
+
* 保证仍成立——但那条腿已随 #177 搬到 governance 层,deny-wins 是 tightenTaskSpec 折叠给的,不再依赖
|
|
197
|
+
* 本 wiring 的组合点。) */
|
|
197
198
|
isExempt?: (toolName: string, canonicalPath: string) => boolean | Promise<boolean>;
|
|
198
|
-
/** ③ (core 1.295): the sensitive-path write DENY set composed onto the fs-write gate leg —
|
|
199
|
-
* `createSensitivePathPolicy({env, patterns, rootPath: cwd})`, deny-wins in the SAME combinePolicies fold, so a
|
|
200
|
-
* session exemption (isExempt) or an acceptEdits auto-allow can never cross it (the 1.199-batch documented gap,
|
|
201
|
-
* now closed). Source = config `SENSITIVE_WRITE_PATTERNS` (unset ⇒ core RECOMMENDED_SENSITIVE_PATTERNS; the
|
|
202
|
-
* set curation is core's — the server passes through). Absent/empty ⇒ no sensitive policy (pre-1.295 shape). */
|
|
203
|
-
sensitivePatterns?: string[];
|
|
204
199
|
}
|
|
205
200
|
export declare function deriveSettingsPolicy(settings: ParsedTaskSettings, gate?: FsWriteGateWiring, workflowGate?: WorkflowGateWiring): {
|
|
206
201
|
toolPolicy?: ToolPolicy;
|
package/dist/task-settings.js
CHANGED
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
* - (`hooks` NO LONGER deferred either — 轴B #2 注释保鲜:applied 形已接线(本文件 hooks 分片 +
|
|
36
36
|
* main.ts 单用户闸),仅 malformed 才报 deferred;旧「需要 remote hook-runner」是接线前拍照。)
|
|
37
37
|
*/
|
|
38
|
-
import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy,
|
|
39
|
-
import { findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
|
|
38
|
+
import { combinePolicies, createAllowDenyPolicy, createFsWriteGatePolicy, tightenTaskSpec, resolveReasoningProfile, rankOf, isThinkingLevel, CYBER_RISK, URL_SAFETY, HARNESS_SECTION_ANCHOR, } from "@sema-agent/core";
|
|
39
|
+
import { findCcRuleFormNames, findUnmatchableToolNames, formatUnmatchableToolNames } from "./config.js";
|
|
40
40
|
import { parseHooksConfig } from "./hooks/hook-runner.js";
|
|
41
41
|
/**
|
|
42
42
|
* R4: coerce a raw top-level `body.permissionMode` (the LIGHT per-turn wire — the shell sends the
|
|
@@ -367,9 +367,12 @@ function createWorkflowPlanDenyPolicy() {
|
|
|
367
367
|
* silent on this leg. Throwing here surfaces as a 422 at the HTTP boundary (resolve-spec already wraps every
|
|
368
368
|
* throw out of `applyTaskSettings`), i.e. the same fail-loud posture the other two legs have.
|
|
369
369
|
*
|
|
370
|
-
* `allow` is deliberately NOT judged: it is a WHITELIST — a name that matches nothing leaves
|
|
371
|
-
* effectively empty, so every tool falls to "not in the allowlist" and is DENIED. That fails closed
|
|
372
|
-
* immediately visible to the caller, which is a different (and acceptable) failure mode from a silent hole.
|
|
370
|
+
* `allow` is deliberately NOT judged by the GENERAL arm: it is a WHITELIST — a name that matches nothing leaves
|
|
371
|
+
* the allowlist effectively empty, so every tool falls to "not in the allowlist" and is DENIED. That fails closed
|
|
372
|
+
* and is immediately visible to the caller, which is a different (and acceptable) failure mode from a silent hole.
|
|
373
|
+
* The ONE exception is the CC-rule-form arm ({@link findCcRuleFormNames}, #186): `allow: ["Bash(ps:*)"]` is the
|
|
374
|
+
* shape a CC user writes to get FEWER prompts and it silently costs them Bash entirely — a spelling-family error,
|
|
375
|
+
* not a typo, so it is refused on all three lists. See the config.ts docblock for the two knobs it points at.
|
|
373
376
|
*/
|
|
374
377
|
function rejectUnmatchableSettingsNames(perms) {
|
|
375
378
|
for (const [field, names] of [["permissions.ask", perms.ask], ["permissions.deny", perms.deny]]) {
|
|
@@ -379,6 +382,11 @@ function rejectUnmatchableSettingsNames(perms) {
|
|
|
379
382
|
if (bad.length > 0)
|
|
380
383
|
throw new Error(formatUnmatchableToolNames(field, bad));
|
|
381
384
|
}
|
|
385
|
+
if (perms.allow) {
|
|
386
|
+
const bad = findCcRuleFormNames(perms.allow);
|
|
387
|
+
if (bad.length > 0)
|
|
388
|
+
throw new Error(formatUnmatchableToolNames("permissions.allow", bad));
|
|
389
|
+
}
|
|
382
390
|
}
|
|
383
391
|
export function deriveSettingsPolicy(settings, gate, workflowGate) {
|
|
384
392
|
const perms = settings.permissions;
|
|
@@ -395,20 +403,17 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
|
|
|
395
403
|
policies.push(createAskListPolicy(ask));
|
|
396
404
|
// The mode-derived fs-write ask gate (core 1.290 factory — real shape read in dist: {env, rootPath?, acceptDirs?,
|
|
397
405
|
// exemptDirs?, defaultWrite}, gates Write/Edit/NotebookEdit, exempt→accept→default layering, canonicalize inside
|
|
398
|
-
// the given env, only ever allow/ask). undefined without wiring (see FsWriteGateWiring). #156: the sandbox
|
|
399
|
-
// supply wiring too — same factory, a
|
|
400
|
-
// what `resolve-spec.ts` puts in the wiring, not in a second code path here.
|
|
401
|
-
// ③
|
|
402
|
-
//
|
|
403
|
-
// neither the gate's own allow legs (exemptDirs/acceptDirs/isExempt session exemption) nor a settings allow can
|
|
404
|
-
// cross it (the 1.199-batch gap: an exempted tool could write .git/hooks — closed here). Same env/rootPath as
|
|
405
|
-
// the gate (the fs the hands really write; symlink canonicalization inside the policy, dist-read).
|
|
406
|
+
// the given env, only ever allow/ask). undefined without wiring (see FsWriteGateWiring). #156/#165: the sandbox
|
|
407
|
+
// lanes supply wiring too — same factory, a deferred real-env proxy and no cwd; the shape difference lives
|
|
408
|
+
// entirely in what `resolve-spec.ts` puts in the wiring, not in a second code path here.
|
|
409
|
+
// (③ sensitive-path DENY 已随 #177 搬 governance 层——见 FsWriteGateWiring 尾注。deny-wins 由
|
|
410
|
+
// tightenTaskSpec 折叠保证:豁免/acceptDirs/settings allow 仍然越不过部署 DENY 基线。)
|
|
406
411
|
const fsWriteGate = (acceptCwd) => {
|
|
407
412
|
if (!gate)
|
|
408
413
|
return undefined;
|
|
409
|
-
// #156: `cwd` is optional (absent on the sandbox lanes). Omitting `rootPath` makes core leave a relative
|
|
410
|
-
// target relative →
|
|
411
|
-
// no auto-allow domain at all. Both are the intended fail-safe, NOT a degraded
|
|
414
|
+
// #156/#165: `cwd` is optional (absent on the sandbox lanes). Omitting `rootPath` makes core leave a relative
|
|
415
|
+
// target relative → the sandbox proxy's lexical `absolutePath` refuses it → ask; omitting `acceptDirs` means
|
|
416
|
+
// acceptEdits mints no auto-allow domain at all. Both are the intended fail-safe, NOT a degraded host arm.
|
|
412
417
|
const gatePolicy = createFsWriteGatePolicy({
|
|
413
418
|
env: gate.env,
|
|
414
419
|
...(gate.cwd !== undefined ? { rootPath: gate.cwd } : {}),
|
|
@@ -422,9 +427,7 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
|
|
|
422
427
|
// NotebookEdit 时 notebook_path 恒先(诱饵 file_path 失效),其余工具 file_path 先(反向诱饵也封);均 dist
|
|
423
428
|
// 亲读。回归锚保留在 test/task-settings.test.ts(NotebookEdit deny + 双向诱饵),现在锁的是 core 行为经
|
|
424
429
|
// 我方折叠——core 若回退,锚变红。
|
|
425
|
-
return
|
|
426
|
-
? combinePolicies(createSensitivePathPolicy({ env: gate.env, patterns: gate.sensitivePatterns, ...(gate.cwd !== undefined ? { rootPath: gate.cwd } : {}) }), gatePolicy)
|
|
427
|
-
: gatePolicy;
|
|
430
|
+
return gatePolicy;
|
|
428
431
|
};
|
|
429
432
|
const compose = (...extra) => {
|
|
430
433
|
const all = [...policies, ...extra.filter((p) => p !== undefined)];
|