@sema-agent/server 7.45.0 → 7.46.0-rc.2
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 +38 -0
- package/dist/approval-card.d.ts +106 -40
- package/dist/approval-card.js +45 -8
- package/dist/boot/coordinators.d.ts +1 -1
- package/dist/boot/memory-consolidation.d.ts +191 -0
- package/dist/boot/memory-consolidation.js +132 -0
- package/dist/boot/runner-deps.d.ts +1 -1
- package/dist/config-types.d.ts +48 -2
- package/dist/config-types.js +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +53 -3
- package/dist/http/routes/capabilities.js +4 -0
- package/dist/http/routes/memory-compliance.d.ts +102 -0
- package/dist/http/routes/memory-compliance.js +113 -0
- package/dist/http/routes/memory-consolidation.d.ts +60 -0
- package/dist/http/routes/memory-consolidation.js +155 -0
- package/dist/http/routes/memory-origin.d.ts +124 -0
- package/dist/http/routes/memory-origin.js +193 -0
- package/dist/http/routes/rules.js +1 -1
- package/dist/http/routes/side-query.js +1 -0
- package/dist/http/server.d.ts +71 -2
- package/dist/http/server.js +27 -2
- package/dist/main.js +55 -2
- package/dist/memory-operator-faces.d.ts +211 -0
- package/dist/memory-operator-faces.js +76 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
- package/dist/plugins/checkpoint-store-sql.js +31 -17
- package/dist/plugins/local-checkpoint-store.js +3 -3
- package/dist/plugins/permission-rule-store-file.d.ts +2 -2
- package/dist/plugins/permission-rule-store-file.js +41 -4
- package/dist/plugins/permission-rule-store-sql.d.ts +2 -2
- package/dist/plugins/permission-rule-store-sql.js +59 -18
- package/dist/plugins/store-backend.d.ts +1 -1
- package/dist/plugins/tidb-pool.js +3 -3
- package/dist/rules-consent.d.ts +30 -3
- package/dist/rules-consent.js +50 -7
- package/dist/task-cwd.d.ts +1 -1
- package/dist/tool-approval.d.ts +43 -10
- package/dist/tool-approval.js +105 -27
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/package.json +3 -3
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
3
3
|
import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
|
|
4
4
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
5
|
-
import {
|
|
5
|
+
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema } from "../approval-card.js";
|
|
6
6
|
import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
|
|
7
7
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
8
8
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
@@ -14,27 +14,41 @@ export const TERMINAL_GRACE_MS = 3_600_000;
|
|
|
14
14
|
export function tokenFingerprint(token) {
|
|
15
15
|
return "sha256:" + createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
16
16
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const MAX_RULE_OFFER_SCAN = 32;
|
|
18
|
+
function preboundBatchMembers(item) {
|
|
19
|
+
if (typeof item !== "object" || item === null)
|
|
20
|
+
return item;
|
|
21
|
+
const candidate = item;
|
|
22
|
+
if (candidate.kind !== "batch" || !Array.isArray(candidate.rules) || candidate.rules.length <= MAX_RULE_OFFER_BATCH_MEMBERS)
|
|
23
|
+
return item;
|
|
24
|
+
return { ...candidate, rules: candidate.rules.slice(0, MAX_RULE_OFFER_BATCH_MEMBERS) };
|
|
25
|
+
}
|
|
26
|
+
export function boundedRuleOffers(raw, opts = {}) {
|
|
19
27
|
if (!Array.isArray(raw))
|
|
20
28
|
return undefined;
|
|
21
29
|
const observe = opts.countRedactions ?? true;
|
|
30
|
+
const scrub = (text) => redactSecrets(text, { observe }).slice(0, MAX_RULE_TEXT_CHARS);
|
|
22
31
|
const out = [];
|
|
23
|
-
for (const item of raw.slice(0,
|
|
24
|
-
if (out.length >=
|
|
32
|
+
for (const item of raw.slice(0, MAX_RULE_OFFER_SCAN)) {
|
|
33
|
+
if (out.length >= MAX_RULE_OFFERS)
|
|
25
34
|
break;
|
|
26
|
-
const parsed =
|
|
35
|
+
const parsed = RuleOfferRawSchema.safeParse(preboundBatchMembers(item));
|
|
27
36
|
if (!parsed.success)
|
|
28
37
|
continue;
|
|
29
|
-
|
|
30
|
-
rule:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
if (parsed.data.kind === "single") {
|
|
39
|
+
out.push({ kind: "single", rule: scrub(parsed.data.rule), match: parsed.data.match, command: scrub(parsed.data.command) });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
out.push({
|
|
43
|
+
kind: "batch",
|
|
44
|
+
rules: parsed.data.rules.slice(0, MAX_RULE_OFFER_BATCH_MEMBERS).map((r) => ({ rule: scrub(r.rule), match: r.match, command: scrub(r.command), segment: scrub(r.segment) })),
|
|
45
|
+
uncoveredSegments: parsed.data.uncoveredSegments,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
34
48
|
}
|
|
35
49
|
return out.length > 0 ? out : undefined;
|
|
36
50
|
}
|
|
37
|
-
function
|
|
51
|
+
function readRuleOffersCell(cell) {
|
|
38
52
|
let parsed;
|
|
39
53
|
try {
|
|
40
54
|
parsed = parseJson(cell);
|
|
@@ -43,7 +57,7 @@ function readRuleSuggestionsCell(cell) {
|
|
|
43
57
|
recordFailOpen("server.approvals.rule-suggestions-cell-unreadable");
|
|
44
58
|
return undefined;
|
|
45
59
|
}
|
|
46
|
-
return
|
|
60
|
+
return boundedRuleOffers(parsed, { countRedactions: false });
|
|
47
61
|
}
|
|
48
62
|
function readRiskDescriptorCell(cell) {
|
|
49
63
|
try {
|
|
@@ -207,7 +221,7 @@ export class SqlCheckpointStore {
|
|
|
207
221
|
const pa = cp.pendingAction;
|
|
208
222
|
const version = checkpointVersionOf(cp);
|
|
209
223
|
const toolInput = boundedToolInput(pa?.args);
|
|
210
|
-
const
|
|
224
|
+
const ruleOffers = boundedRuleOffers(pa?.ruleOffers);
|
|
211
225
|
try {
|
|
212
226
|
await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions) " +
|
|
213
227
|
"VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions) " +
|
|
@@ -226,7 +240,7 @@ export class SqlCheckpointStore {
|
|
|
226
240
|
cp.gate?.kind ?? null,
|
|
227
241
|
pa?.boundInputHash ?? null,
|
|
228
242
|
((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
|
|
229
|
-
|
|
243
|
+
ruleOffers === undefined ? null : this.json(ruleOffers, "rule offers"),
|
|
230
244
|
]);
|
|
231
245
|
}
|
|
232
246
|
catch (e) {
|
|
@@ -340,7 +354,7 @@ export class SqlCheckpointStore {
|
|
|
340
354
|
const toolCallId = r.tool_call_id ?? null;
|
|
341
355
|
const boundInputHash = r.bound_input_hash ?? null;
|
|
342
356
|
const gateKind = r.gate_kind ?? null;
|
|
343
|
-
const
|
|
357
|
+
const ruleOffers = readRuleOffersCell(r.rule_suggestions);
|
|
344
358
|
return {
|
|
345
359
|
sessionId: String(r.session_id),
|
|
346
360
|
scope: String(r.scope),
|
|
@@ -354,7 +368,7 @@ export class SqlCheckpointStore {
|
|
|
354
368
|
createdAt: Number(r.created_at_ms),
|
|
355
369
|
deadline: r.deadline == null ? null : Number(r.deadline),
|
|
356
370
|
riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
|
|
357
|
-
...(
|
|
371
|
+
...(ruleOffers !== undefined ? { ruleOffers } : {}),
|
|
358
372
|
};
|
|
359
373
|
});
|
|
360
374
|
return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSyn
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { isApprovalGateKind } from "../tool-approval.js";
|
|
5
5
|
import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
|
|
6
|
-
import {
|
|
6
|
+
import { boundedRuleOffers, boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
|
|
7
7
|
import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
8
8
|
function terminalAtOf(cp) {
|
|
9
9
|
return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
|
|
@@ -156,8 +156,8 @@ export class LocalCheckpointStore {
|
|
|
156
156
|
deadline: cp.deadline ?? null,
|
|
157
157
|
riskDescriptor: gate?.riskDescriptor ?? null,
|
|
158
158
|
...(() => {
|
|
159
|
-
const rs =
|
|
160
|
-
return rs !== undefined ? {
|
|
159
|
+
const rs = boundedRuleOffers(pa?.ruleOffers);
|
|
160
|
+
return rs !== undefined ? { ruleOffers: rs } : {};
|
|
161
161
|
})(),
|
|
162
162
|
});
|
|
163
163
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope } from "@sema-agent/core";
|
|
1
|
+
import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
|
|
2
2
|
import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
|
|
3
3
|
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
4
4
|
/**
|
|
@@ -19,7 +19,7 @@ export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordSt
|
|
|
19
19
|
* 关后写面抛 `log_closed`(core 语义):**不重开**,因为「这只店已经交还」与「这条命还能写」
|
|
20
20
|
* 不能两立,静默重开会让一次 dispose 之后的写落进一份没人再读的日志。 */
|
|
21
21
|
close(): void;
|
|
22
|
-
get(id: string): Promise<RuleApprovalRecord | undefined>;
|
|
22
|
+
get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
|
|
23
23
|
create(record: RuleApprovalRecord): Promise<void>;
|
|
24
24
|
cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
|
|
25
25
|
/** 与 SQL 侧同名方法同义(超帽拒绝时收掉 `prepareCcImport` 已落盘的那条 pending 记录)。
|
|
@@ -9,21 +9,34 @@ const RuleScopeSchema = z.union([
|
|
|
9
9
|
]);
|
|
10
10
|
const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
|
|
11
11
|
const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
|
|
12
|
+
const RuleOffer2Schema = z.union([
|
|
13
|
+
z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
|
|
14
|
+
z
|
|
15
|
+
.object({
|
|
16
|
+
kind: z.literal("batch"),
|
|
17
|
+
candidates: z.array(z.number().int().nonnegative().safe()),
|
|
18
|
+
segments: z.array(z.string()).optional(),
|
|
19
|
+
uncoveredSegments: z.number().int().nonnegative().safe().optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict(),
|
|
22
|
+
]);
|
|
12
23
|
const ApprovalRecordSchema = z
|
|
13
24
|
.object({
|
|
14
25
|
id: z.string().min(1),
|
|
15
26
|
principal: z.string().optional(),
|
|
16
27
|
owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
|
|
28
|
+
schema: z.literal(2),
|
|
17
29
|
kind: z.enum(["card", "import", "starter"]),
|
|
18
30
|
state: z.enum(["pending", "approved", "redeemed"]),
|
|
19
31
|
candidates: z.array(RuleCandidateSchema),
|
|
32
|
+
offers: z.array(RuleOffer2Schema),
|
|
20
33
|
createdAt: z.string(),
|
|
21
34
|
toolCallId: z.string().optional(),
|
|
22
35
|
boundInputHash: z.string().optional(),
|
|
23
36
|
command: z.string().optional(),
|
|
24
37
|
edited: z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict().optional(),
|
|
25
38
|
rev: z.number().int().nonnegative().safe(),
|
|
26
|
-
|
|
39
|
+
selectedOffer: z.number().int().nonnegative().safe().optional(),
|
|
27
40
|
redeemedDots: z.record(z.string(), RuleDotSchema).optional(),
|
|
28
41
|
})
|
|
29
42
|
.strict();
|
|
@@ -32,21 +45,40 @@ function toApprovalRecord(row) {
|
|
|
32
45
|
id: row.id,
|
|
33
46
|
...(row.principal !== undefined ? { principal: row.principal } : {}),
|
|
34
47
|
...(row.owner !== undefined ? { owner: row.owner } : {}),
|
|
48
|
+
schema: row.schema,
|
|
35
49
|
kind: row.kind,
|
|
36
50
|
state: row.state,
|
|
37
51
|
candidates: row.candidates,
|
|
52
|
+
offers: row.offers,
|
|
38
53
|
createdAt: row.createdAt,
|
|
39
54
|
...(row.toolCallId !== undefined ? { toolCallId: row.toolCallId } : {}),
|
|
40
55
|
...(row.boundInputHash !== undefined ? { boundInputHash: row.boundInputHash } : {}),
|
|
41
56
|
...(row.command !== undefined ? { command: row.command } : {}),
|
|
42
57
|
...(row.edited !== undefined ? { edited: row.edited } : {}),
|
|
43
58
|
rev: row.rev,
|
|
44
|
-
...(row.
|
|
59
|
+
...(row.selectedOffer !== undefined ? { selectedOffer: row.selectedOffer } : {}),
|
|
45
60
|
...(row.redeemedDots !== undefined
|
|
46
61
|
? { redeemedDots: Object.fromEntries(Object.entries(row.redeemedDots).map(([k, v]) => [Number(k), v])) }
|
|
47
62
|
: {}),
|
|
48
63
|
};
|
|
49
64
|
}
|
|
65
|
+
const StaleApprovalRowSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
id: z.string().min(1),
|
|
68
|
+
schema: z.unknown().optional(),
|
|
69
|
+
principal: z.string().optional(),
|
|
70
|
+
owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
|
|
71
|
+
})
|
|
72
|
+
.loose();
|
|
73
|
+
const StaleApprovalLineSchema = z.object({ op: z.literal("put"), record: StaleApprovalRowSchema }).loose();
|
|
74
|
+
function toStaleEnvelope(row) {
|
|
75
|
+
return {
|
|
76
|
+
staleSchema: true,
|
|
77
|
+
id: row.id,
|
|
78
|
+
...(row.principal !== undefined ? { principal: row.principal } : {}),
|
|
79
|
+
...(row.owner !== undefined ? { owner: row.owner } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
50
82
|
const ApprovalLineSchema = z.union([
|
|
51
83
|
z.object({ op: z.literal("put"), record: ApprovalRecordSchema }).strict(),
|
|
52
84
|
z.object({ op: z.literal("discard"), id: z.string().min(1) }).strict(),
|
|
@@ -100,6 +132,11 @@ export class FileRuleApprovalRecordStore {
|
|
|
100
132
|
for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
|
|
101
133
|
const parsed = ApprovalLineSchema.safeParse(raw);
|
|
102
134
|
if (!parsed.success) {
|
|
135
|
+
const stale = StaleApprovalLineSchema.safeParse(raw);
|
|
136
|
+
if (stale.success && stale.data.record.schema !== 2) {
|
|
137
|
+
this.rows.set(stale.data.record.id, toStaleEnvelope(stale.data.record));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
103
140
|
this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
|
|
104
141
|
continue;
|
|
105
142
|
}
|
|
@@ -134,7 +171,7 @@ export class FileRuleApprovalRecordStore {
|
|
|
134
171
|
throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
|
|
135
172
|
}
|
|
136
173
|
const cur = this.rows.get(id);
|
|
137
|
-
if (cur === undefined || cur.rev !== expectRev)
|
|
174
|
+
if (cur === undefined || "staleSchema" in cur || cur.rev !== expectRev)
|
|
138
175
|
return false;
|
|
139
176
|
this.log.append({ op: "put", record: next }, true);
|
|
140
177
|
this.rows.set(id, structuredClone(next));
|
|
@@ -143,7 +180,7 @@ export class FileRuleApprovalRecordStore {
|
|
|
143
180
|
async discardPendingRecord(recordId) {
|
|
144
181
|
this.gate.assertUsable();
|
|
145
182
|
const cur = this.rows.get(recordId);
|
|
146
|
-
if (cur === undefined || cur.state !== "pending")
|
|
183
|
+
if (cur === undefined || "staleSchema" in cur || cur.state !== "pending")
|
|
147
184
|
return false;
|
|
148
185
|
this.log.append({ op: "discard", id: recordId }, true);
|
|
149
186
|
this.rows.delete(recordId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
|
|
1
|
+
import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
|
|
2
2
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
3
3
|
import type { PgQueryFn } from "./pg-query.js";
|
|
4
4
|
import { type SqlDriver } from "./sql-driver.js";
|
|
@@ -82,7 +82,7 @@ export declare class SqlRuleApprovalRecordStore implements RuleApprovalRecordSto
|
|
|
82
82
|
constructor(db: SqlDriver, now?: () => number);
|
|
83
83
|
private q;
|
|
84
84
|
private enc;
|
|
85
|
-
get(id: string): Promise<RuleApprovalRecord | undefined>;
|
|
85
|
+
get(id: string): Promise<RuleApprovalRecord | StaleRuleApprovalRecord | undefined>;
|
|
86
86
|
create(record: RuleApprovalRecord): Promise<void>;
|
|
87
87
|
/**
|
|
88
88
|
* 丢弃一条**从未被确认**的记录(codex 交叉复审 round8 [medium],可控半场)。
|
|
@@ -51,6 +51,17 @@ const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe(
|
|
|
51
51
|
const RuleCandidateSchema = z.object({ rule: z.string(), scope: RuleScopeSchema }).strict();
|
|
52
52
|
const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
|
|
53
53
|
const RuleEditedSchema = z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict();
|
|
54
|
+
const RuleOffer2Schema = z.union([
|
|
55
|
+
z.object({ kind: z.literal("single"), candidate: z.number().int().nonnegative().safe() }).strict(),
|
|
56
|
+
z
|
|
57
|
+
.object({
|
|
58
|
+
kind: z.literal("batch"),
|
|
59
|
+
candidates: z.array(z.number().int().nonnegative().safe()),
|
|
60
|
+
segments: z.array(z.string()).optional(),
|
|
61
|
+
uncoveredSegments: z.number().int().nonnegative().safe().optional(),
|
|
62
|
+
})
|
|
63
|
+
.strict(),
|
|
64
|
+
]);
|
|
54
65
|
export const PERMISSION_RULE_TABLE = "permission_rule";
|
|
55
66
|
export const PERMISSION_RULE_APPROVAL_TABLE = "permission_rule_approval";
|
|
56
67
|
export const PERMISSION_RULE_TICKET_TABLE = "permission_rule_ticket";
|
|
@@ -87,7 +98,19 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
87
98
|
state VARCHAR(32) NOT NULL,
|
|
88
99
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
89
100
|
candidates_json LONGTEXT NOT NULL,
|
|
90
|
-
|
|
101
|
+
-- version:**数据格式版本**(= core \`RuleApprovalRecord.schema\`,本形恒 2;OCC 守卫另在 rev,
|
|
102
|
+
-- 两轴禁混,schema-naming 门③ 白名单登记)。读侧对非 2 的行交还 design/375 §4.5 的
|
|
103
|
+
-- StaleRuleApprovalRecord 信封(identity 键 only)—— 本仓 SCHEMA POLICY 是删表重建,重建后的表
|
|
104
|
+
-- 不该有这种行,这一臂是「未来再换形」的防御位,绝不静默 widen 成一条本形担保不了的记录。
|
|
105
|
+
version INT NOT NULL,
|
|
106
|
+
-- offers_json:core \`RuleOffer2[]\`(design/375 §4.1)—— 按 index 引用 candidates 的 OPTION 结构,
|
|
107
|
+
-- **绝不存规则文本副本**(显示三元组由 core \`ruleOffersOfRecord\` 从候选文本投影;存副本=造一个
|
|
108
|
+
-- 与真会落地的规则漂移的面)。batch 臂上 segments/uncoveredSegments 是 CARD 记录的铸造时事实。
|
|
109
|
+
offers_json LONGTEXT NOT NULL,
|
|
110
|
+
-- selected_offer:确认转移记下的 **OFFER index**(design/375 §4.3,接替 selected_candidate 的席位)。
|
|
111
|
+
-- ⚠️ 两个 index 空间刻意分离:确认键 OFFER index(本列),而 ticket / redeemed_dots_json 仍键
|
|
112
|
+
-- CANDIDATE index —— 兑付/重放锚不随卡面 OPTION 结构漂。
|
|
113
|
+
selected_offer INT NULL,
|
|
91
114
|
redeemed_dots_json LONGTEXT NULL,
|
|
92
115
|
tool_call_id VARCHAR(255) NULL,
|
|
93
116
|
bound_input_hash VARCHAR(255) NULL,
|
|
@@ -147,14 +170,16 @@ function isMissingColumnError(err, dialect) {
|
|
|
147
170
|
}
|
|
148
171
|
export async function assertPermissionRuleApprovalSchema(query, dialect) {
|
|
149
172
|
try {
|
|
150
|
-
await query(`SELECT command, edited_json FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
|
|
173
|
+
await query(`SELECT command, edited_json, version, offers_json, selected_offer FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE 1=0`);
|
|
151
174
|
}
|
|
152
175
|
catch (err) {
|
|
153
176
|
if (!isMissingColumnError(err, dialect))
|
|
154
177
|
throw err;
|
|
155
|
-
throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} is missing
|
|
156
|
-
|
|
157
|
-
`
|
|
178
|
+
throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} is missing required columns — refusing to start. This build needs the ` +
|
|
179
|
+
`#340 card-edit columns (command / edited_json) and the design/375 record-form columns ` +
|
|
180
|
+
`(version / offers_json / selected_offer): the approval record now stores its OPTION structure and the ` +
|
|
181
|
+
`chosen offer, and the store refuses to read rows whose record-form version it cannot vouch for. ` +
|
|
182
|
+
`This repository ships no ` +
|
|
158
183
|
`ALTER TABLE migrations, so the table must be recreated: run \`DROP TABLE ${PERMISSION_RULE_APPROVAL_TABLE};\` on the ` +
|
|
159
184
|
`${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the schema is recreated at boot. ` +
|
|
160
185
|
`The cost is bounded: this table holds pending/settled approval RECORDS (the audit trail of who said yes to which ` +
|
|
@@ -190,7 +215,13 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
190
215
|
state VARCHAR(32) COLLATE "C" NOT NULL,
|
|
191
216
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
192
217
|
candidates_json TEXT COLLATE "C" NOT NULL,
|
|
193
|
-
|
|
218
|
+
-- version / offers_json / selected_offer:design/375 换形三列(MySQL 孪生的行内注写了理由:
|
|
219
|
+
-- version=数据格式版本恒 2,非 2 的行读成 Stale 信封;offers_json=按 index 引用的 OPTION 结构,
|
|
220
|
+
-- 禁存文本副本;selected_offer=确认的 OFFER index,与 ticket/redeemed_dots 的 CANDIDATE index
|
|
221
|
+
-- 两空间分离)。
|
|
222
|
+
version INT NOT NULL,
|
|
223
|
+
offers_json TEXT COLLATE "C" NOT NULL,
|
|
224
|
+
selected_offer INT,
|
|
194
225
|
redeemed_dots_json TEXT COLLATE "C",
|
|
195
226
|
tool_call_id VARCHAR(255) COLLATE "C",
|
|
196
227
|
bound_input_hash VARCHAR(255) COLLATE "C",
|
|
@@ -445,11 +476,18 @@ export class SqlRuleApprovalRecordStore {
|
|
|
445
476
|
return dialectProtocolJsonEncoder(this.db.dialect)(v, label);
|
|
446
477
|
}
|
|
447
478
|
async get(id) {
|
|
448
|
-
const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, rev, candidates_json,
|
|
479
|
+
const { rows } = await this.db.query(this.q(`SELECT record_id, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ?`, `SELECT record_id, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1`), [id]);
|
|
449
480
|
const row = rows[0];
|
|
450
481
|
if (row === undefined)
|
|
451
482
|
return undefined;
|
|
483
|
+
const ownerKind = String(row.owner_kind);
|
|
484
|
+
const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
|
|
485
|
+
const identity = ownerKind === "principal" ? { principal: principal ?? "" } : { owner: { kind: "local-owner" } };
|
|
486
|
+
if (Number(row.version) !== 2) {
|
|
487
|
+
return { staleSchema: true, id: String(row.record_id), ...identity };
|
|
488
|
+
}
|
|
452
489
|
const candidates = parseColumn(z.array(RuleCandidateSchema), row.candidates_json, "candidates_json", id);
|
|
490
|
+
const offers = parseColumn(z.array(RuleOffer2Schema), row.offers_json, "offers_json", id);
|
|
453
491
|
const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : parseColumn(RedeemedDotsSchema, row.redeemed_dots_json, "redeemed_dots_json", id);
|
|
454
492
|
const edited = row.edited_json === null || row.edited_json === undefined ? undefined : parseColumn(RuleEditedSchema, row.edited_json, "edited_json", id);
|
|
455
493
|
const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
|
|
@@ -457,40 +495,42 @@ export class SqlRuleApprovalRecordStore {
|
|
|
457
495
|
if (!kind.success || !state.success) {
|
|
458
496
|
throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} row ${id} carries an unreadable kind/state (${String(row.kind)}/${String(row.state)})`);
|
|
459
497
|
}
|
|
460
|
-
const ownerKind = String(row.owner_kind);
|
|
461
|
-
const principal = row.principal === null || row.principal === undefined ? undefined : String(row.principal);
|
|
462
498
|
const redeemedDots = dotsRaw === undefined ? undefined : Object.fromEntries(Object.entries(dotsRaw).map(([k, v]) => [Number(k), v]));
|
|
463
499
|
return {
|
|
464
500
|
id: String(row.record_id),
|
|
465
|
-
...
|
|
501
|
+
...identity,
|
|
502
|
+
schema: 2,
|
|
466
503
|
kind: kind.data,
|
|
467
504
|
state: state.data,
|
|
468
505
|
candidates,
|
|
506
|
+
offers,
|
|
469
507
|
createdAt: String(row.created_at_iso),
|
|
470
508
|
rev: Number(row.rev ?? 0),
|
|
471
509
|
...(row.tool_call_id !== null && row.tool_call_id !== undefined ? { toolCallId: String(row.tool_call_id) } : {}),
|
|
472
510
|
...(row.bound_input_hash !== null && row.bound_input_hash !== undefined ? { boundInputHash: String(row.bound_input_hash) } : {}),
|
|
473
511
|
...(row.command !== null && row.command !== undefined ? { command: String(row.command) } : {}),
|
|
474
512
|
...(edited !== undefined ? { edited } : {}),
|
|
475
|
-
...(row.
|
|
513
|
+
...(row.selected_offer !== null && row.selected_offer !== undefined ? { selectedOffer: Number(row.selected_offer) } : {}),
|
|
476
514
|
...(redeemedDots !== undefined ? { redeemedDots } : {}),
|
|
477
515
|
};
|
|
478
516
|
}
|
|
479
517
|
async create(record) {
|
|
480
518
|
const nowMs = this.now();
|
|
481
519
|
const owner = record.owner ?? { kind: "principal", principal: record.principal ?? "" };
|
|
482
|
-
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json,
|
|
483
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, rev, candidates_json,
|
|
484
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`), [
|
|
520
|
+
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
|
|
521
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_APPROVAL_TABLE} (record_id, owner_key, owner_kind, principal, kind, state, version, rev, candidates_json, offers_json, selected_offer, redeemed_dots_json, tool_call_id, bound_input_hash, command, edited_json, created_at_iso, created_at_ms, updated_at_ms)
|
|
522
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`), [
|
|
485
523
|
record.id,
|
|
486
524
|
buildRuleOwnerKey(owner),
|
|
487
525
|
owner.kind,
|
|
488
526
|
owner.kind === "principal" ? owner.principal : null,
|
|
489
527
|
record.kind,
|
|
490
528
|
record.state,
|
|
529
|
+
record.schema,
|
|
491
530
|
record.rev,
|
|
492
531
|
this.enc(record.candidates, "permission_rule_approval.candidates_json"),
|
|
493
|
-
record.
|
|
532
|
+
this.enc(record.offers, "permission_rule_approval.offers_json"),
|
|
533
|
+
record.selectedOffer ?? null,
|
|
494
534
|
record.redeemedDots === undefined ? null : this.enc(record.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
|
|
495
535
|
record.toolCallId ?? null,
|
|
496
536
|
record.boundInputHash ?? null,
|
|
@@ -502,18 +542,19 @@ export class SqlRuleApprovalRecordStore {
|
|
|
502
542
|
]);
|
|
503
543
|
}
|
|
504
544
|
async discardPendingRecord(recordId) {
|
|
505
|
-
const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending'`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending'`), [recordId]);
|
|
545
|
+
const { affected } = await this.db.query(this.q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = ? AND state = 'pending' AND version = 2`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE record_id = $1 AND state = 'pending' AND version = 2`), [recordId]);
|
|
506
546
|
return affected === 1;
|
|
507
547
|
}
|
|
508
548
|
async cas(id, expectRev, next) {
|
|
509
549
|
if (next.rev !== expectRev + 1) {
|
|
510
550
|
throw new Error(`rule-approval CAS must advance rev by exactly one (expectRev=${expectRev}, next.rev=${next.rev})`);
|
|
511
551
|
}
|
|
512
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?,
|
|
552
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = ?, rev = ?, candidates_json = ?, offers_json = ?, selected_offer = ?, redeemed_dots_json = ?, edited_json = ?, updated_at_ms = ? WHERE record_id = ? AND rev = ? AND version = 2`, `UPDATE ${PERMISSION_RULE_APPROVAL_TABLE} SET state = $1, rev = $2, candidates_json = $3, offers_json = $4, selected_offer = $5, redeemed_dots_json = $6, edited_json = $7, updated_at_ms = $8 WHERE record_id = $9 AND rev = $10 AND version = 2`), [
|
|
513
553
|
next.state,
|
|
514
554
|
next.rev,
|
|
515
555
|
this.enc(next.candidates, "permission_rule_approval.candidates_json"),
|
|
516
|
-
next.
|
|
556
|
+
this.enc(next.offers, "permission_rule_approval.offers_json"),
|
|
557
|
+
next.selectedOffer ?? null,
|
|
517
558
|
next.redeemedDots === undefined ? null : this.enc(next.redeemedDots, "permission_rule_approval.redeemed_dots_json"),
|
|
518
559
|
next.edited === undefined ? null : this.enc(next.edited, "permission_rule_approval.edited_json"),
|
|
519
560
|
this.now(),
|
|
@@ -142,7 +142,7 @@ export interface StoreBackend {
|
|
|
142
142
|
* 三面 —— 规则桶 provider / durable 审批记录 / server 自铸的 CC 导入票。
|
|
143
143
|
*
|
|
144
144
|
* 🔴 **可选**(与上面那些 REQUIRED 家族不同),缺席是一个真答案:core 的 `RunnerDeps.permissionRuleStore`
|
|
145
|
-
* 本身就是 optional,缺席 ⇒ 引擎的 `permissionRules.storeWired` 如实报 `false`,ask 帧不投 `
|
|
145
|
+
* 本身就是 optional,缺席 ⇒ 引擎的 `permissionRules.storeWired` 如实报 `false`,ask 帧不投 `ruleOffers`
|
|
146
146
|
* (发一格按下去无处可兑的「不再询问」= wire 谎言,比缺席更坏)。
|
|
147
147
|
*
|
|
148
148
|
* `local` 车道**刻意不给 in-memory twin**:一条规则是「人授权过的持久事实」,进程内 Map 形会在重启时
|
|
@@ -250,10 +250,10 @@ export const SCHEMA_STATEMENTS = [
|
|
|
250
250
|
-- triage-sort the supervisor inbox by severity DESC — WITHOUT parsing the checkpoint blob per row.
|
|
251
251
|
-- NULL on gates with no descriptor.
|
|
252
252
|
risk_descriptor TEXT NULL,
|
|
253
|
-
-- rule_suggestions ([3683]-2/[3684]② mint 面归因):core 挂在 pendingAction.
|
|
254
|
-
-- 候选(design/179 §4;与同步腿 AskRequest.
|
|
253
|
+
-- rule_suggestions ([3683]-2/[3684]② mint 面归因):core 挂在 pendingAction.ruleOffers 上的规则
|
|
254
|
+
-- 候选(design/179 §4;与同步腿 AskRequest.ruleOffers 同一条契约;design/375 换形后载荷=判别联合,列名保留),在 put() 落列 —— 于是
|
|
255
255
|
-- listPending 不必为一格展示材料去拖整只 checkpoint blob(那是整份 suspend 快照)。落列前过
|
|
256
|
-
--
|
|
256
|
+
-- boundedRuleOffers:窄读(坏条丢弃)+ redactSecrets(候选文本由命令原文铸出,而运维队列跨租户
|
|
257
257
|
-- 可见)+ 基数截断。NULL = 无供给(规则车道没武装 / 命令说不出规则 / 这只 ask 规则清不掉),读口
|
|
258
258
|
-- 据此省键而不是铸空数组。
|
|
259
259
|
rule_suggestions TEXT NULL,
|
package/dist/rules-consent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RuleOwner, type ImportPreview, type
|
|
1
|
+
import { type RuleOwner, type ImportPreview, type RedeemedBatchMember, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type EditedRuleTextPrecheck, type RuleScope, type RuleOffer } from "@sema-agent/core";
|
|
2
2
|
import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
|
|
3
3
|
import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
|
|
4
4
|
/**
|
|
@@ -80,12 +80,19 @@ export declare const RULE_IMPORT_RETRY_AFTER_SEC = 2;
|
|
|
80
80
|
* 「几百次 SQL」而不是「几万次」。
|
|
81
81
|
*/
|
|
82
82
|
export declare const MAX_IMPORT_CANDIDATES = 200;
|
|
83
|
-
/** 一次导入 prepare 的产物(HTTP 200 体的素材),或**超帽**的拒绝(见 {@link MAX_IMPORT_CANDIDATES})。
|
|
83
|
+
/** 一次导入 prepare 的产物(HTTP 200 体的素材),或**超帽**的拒绝(见 {@link MAX_IMPORT_CANDIDATES})。
|
|
84
|
+
* 5.58(design/375):零 importable 候选 ⇒ core **不铸** `approvalId`(「absence says so」)⇒ 无票可铸,
|
|
85
|
+
* 第三臂把 preview(全 skipped 的理由就在里面)原样交给人看——不是错误,是「没有什么可确认」。 */
|
|
84
86
|
export type RuleImportPrepared = {
|
|
85
87
|
ok: true;
|
|
86
88
|
preview: ImportPreview;
|
|
87
89
|
ticket: string;
|
|
88
90
|
expiresAtMs: number;
|
|
91
|
+
} | {
|
|
92
|
+
ok: true;
|
|
93
|
+
preview: ImportPreview;
|
|
94
|
+
ticket?: undefined;
|
|
95
|
+
expiresAtMs?: undefined;
|
|
89
96
|
} | {
|
|
90
97
|
ok: false;
|
|
91
98
|
reason: "too-many-candidates";
|
|
@@ -95,7 +102,10 @@ export type RuleImportPrepared = {
|
|
|
95
102
|
* 只为服务端日志/诊断。 */
|
|
96
103
|
export type RuleImportRedeemed = {
|
|
97
104
|
ok: true;
|
|
98
|
-
result:
|
|
105
|
+
result: {
|
|
106
|
+
members: readonly RedeemedBatchMember[];
|
|
107
|
+
rev: number;
|
|
108
|
+
};
|
|
99
109
|
}
|
|
100
110
|
/** `retryable` = 这次失败**没有裁定任何事**(store 抛错 / CAS 冲突),认领已放回,属主原样重试即可。
|
|
101
111
|
* 它与裁定性拒绝(载荷被篡改、记录不存在)分家是承重的:把两者塌成一格,一次数据库抖动就会被
|
|
@@ -118,14 +128,31 @@ export type CardRuleRedemption =
|
|
|
118
128
|
| {
|
|
119
129
|
kind: "edited";
|
|
120
130
|
text: string;
|
|
131
|
+
}
|
|
132
|
+
/** design/377:人勾了**合取批**——传的是**行素材上人看到的那只 batch 整只**(不是裸 index),
|
|
133
|
+
* 等式两侧(看到的 / 重铸可兑的)在本车道一个函数里对齐(防漂等式,见 batch 分支行注)。 */
|
|
134
|
+
| {
|
|
135
|
+
kind: "batch";
|
|
136
|
+
offer: Extract<RuleOffer, {
|
|
137
|
+
kind: "batch";
|
|
138
|
+
}>;
|
|
121
139
|
};
|
|
122
140
|
/** 卡道兑付的结果。`canonical` = **真正落盘**的那条规则文本(编辑臂上它可能与人敲的原字节不同 ——
|
|
123
141
|
* core 会把 `Bash(adb *)` 规范成 `Bash(adb:*)`;界面要回显的是这一份,不是输入框里的那一份)。 */
|
|
124
142
|
export type CardRulePersisted = {
|
|
125
143
|
ok: true;
|
|
144
|
+
kind: "single";
|
|
126
145
|
rule: string;
|
|
127
146
|
rev: number;
|
|
128
147
|
alreadyRedeemed: boolean;
|
|
148
|
+
}
|
|
149
|
+
/** design/377 批臂:全体成员落地(persisted/deduped 都计——等价规则已在店=同意已生效)。
|
|
150
|
+
* `rules` = 规范文本,**展示序**(= batch offer 的成员序,壳逐条回显)。 */
|
|
151
|
+
| {
|
|
152
|
+
ok: true;
|
|
153
|
+
kind: "batch";
|
|
154
|
+
rules: readonly string[];
|
|
155
|
+
rev: number;
|
|
129
156
|
} | {
|
|
130
157
|
ok: false;
|
|
131
158
|
reason: "no-candidates" | "unknown-candidate" | "confirm-refused" | "redeem-refused"
|