zigmini 0.8.0-beta.7 → 0.9.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +18 -5
- package/README.md +18 -5
- package/assets/agent/gateway-request-rules.md +3 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +50 -4
- package/docs/manual.md +48 -4
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +1 -1
- package/src/decisions.ts +32 -13
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +57 -31
- package/src/main.ts +22 -7
- package/src/notification-history.ts +56 -0
- package/src/protocol.ts +2 -1
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +355 -37
- package/src/store.ts +165 -5
- package/src/work.ts +21 -7
- package/web/DESIGN.md +39 -0
- package/web/app.js +198 -69
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +3 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +13 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +31 -1
package/package.json
CHANGED
package/src/decisions.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { decisionContext } from "./decision-context.ts";
|
|
3
3
|
import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
|
|
4
|
+
import { securityDecision, SecurityRuntimeError } from "./security-allow.ts";
|
|
4
5
|
import type { OmoEvent } from "./omo/client.ts";
|
|
5
6
|
import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
|
|
6
7
|
import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
|
|
@@ -163,6 +164,10 @@ export class DecisionService {
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
info(decision: Decision): DecisionInfo {
|
|
167
|
+
if (securityDecision(decision)) {
|
|
168
|
+
const security = this.store.securityHold(decision.id)?.state;
|
|
169
|
+
return { ...decision, work: null, allowedActions: [], ...(security ? { security } : {}) };
|
|
170
|
+
}
|
|
166
171
|
const work = decision.workId === null ? null : this.store.getWork(decision.workId);
|
|
167
172
|
let allowedActions: DecisionInfo["allowedActions"] = [];
|
|
168
173
|
if (unresolved(decision) && !["completed", "failed", "cancelled"].includes(work?.status ?? "") &&
|
|
@@ -200,12 +205,17 @@ export class DecisionService {
|
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
answer(id: string, input: unknown, checked = false): DecisionInfo {
|
|
208
|
+
const original = this.store.getDecision(id);
|
|
209
|
+
if (original && securityDecision(original)) throw new DecisionStoreError("security_endpoint_required");
|
|
203
210
|
const request = parseDecisionAnswer(input);
|
|
204
211
|
if (!checked) {
|
|
205
212
|
const owner = this.store.getDecision(id)?.ownerConv ?? "main";
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
const snapshot = structuredClone(request);
|
|
214
|
+
this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async (receipt) => {
|
|
215
|
+
receipt({ kind: "answer", decisionId: id, submissionId: snapshot.submissionId });
|
|
216
|
+
this.answer(id, snapshot, true);
|
|
217
|
+
}, { key: JSON.stringify(["answer", id, request.submissionId]), snapshot,
|
|
218
|
+
originalDecisionId: id, submissionId: request.submissionId });
|
|
209
219
|
}
|
|
210
220
|
this.refresh();
|
|
211
221
|
const before = this.required(id);
|
|
@@ -228,15 +238,15 @@ export class DecisionService {
|
|
|
228
238
|
try {
|
|
229
239
|
if (!this.ports.sendLive(decision)) this.review(id, "receipt_unknown");
|
|
230
240
|
} catch (error) {
|
|
231
|
-
this.error(error);
|
|
241
|
+
this.error(checked ? new SecurityRuntimeError("dispatch_unknown") : error);
|
|
232
242
|
this.review(id, "receipt_unknown");
|
|
233
243
|
}
|
|
234
244
|
} else if (operation?.data.mainInputSeq !== null && operation?.data.mainInputSeq !== undefined) {
|
|
235
245
|
const inputEvent = this.store.getEvent(operation.data.mainInputSeq);
|
|
236
246
|
if (!inputEvent) throw new Error("decision_main_intent_missing");
|
|
237
|
-
this.dispatch(id, () => this.ports.dispatchMain(inputEvent));
|
|
247
|
+
this.dispatch(id, () => this.ports.dispatchMain(inputEvent), checked);
|
|
238
248
|
} else if (operation?.data.commandId && decision.workId) {
|
|
239
|
-
this.dispatch(id, () => this.ports.dispatchWork(decision.workId!));
|
|
249
|
+
this.dispatch(id, () => this.ports.dispatchWork(decision.workId!), checked);
|
|
240
250
|
}
|
|
241
251
|
this.refresh();
|
|
242
252
|
return this.info(this.required(id));
|
|
@@ -247,6 +257,7 @@ export class DecisionService {
|
|
|
247
257
|
* 취소 button; a later answer to the closed decision is rejected as a conflict.
|
|
248
258
|
*/
|
|
249
259
|
dismiss(id: string, input: unknown): DecisionInfo {
|
|
260
|
+
if (securityDecision(this.required(id))) throw new DecisionStoreError("security_endpoint_required");
|
|
250
261
|
const expectedRevision = parseDecisionDismiss(input);
|
|
251
262
|
this.refresh();
|
|
252
263
|
let dismissal;
|
|
@@ -274,9 +285,9 @@ export class DecisionService {
|
|
|
274
285
|
return this.info(this.required(id));
|
|
275
286
|
}
|
|
276
287
|
|
|
277
|
-
private dispatch(id: string, send: () => Promise<void
|
|
288
|
+
private dispatch(id: string, send: () => Promise<void>, protectedPayload = false): void {
|
|
278
289
|
void send().catch((error: unknown) => {
|
|
279
|
-
this.error(error);
|
|
290
|
+
this.error(protectedPayload ? new SecurityRuntimeError("dispatch_unknown") : error);
|
|
280
291
|
if (!this.stopped) this.review(id, "receipt_unknown");
|
|
281
292
|
}).finally(() => {
|
|
282
293
|
if (!this.stopped) this.refresh();
|
|
@@ -523,6 +534,7 @@ export class DecisionService {
|
|
|
523
534
|
this.store.reconcileDecisionReceipts();
|
|
524
535
|
const now = Date.now();
|
|
525
536
|
for (let decision of this.store.allDecisions()) {
|
|
537
|
+
if (securityDecision(decision)) continue;
|
|
526
538
|
if (!unresolved(decision)) continue;
|
|
527
539
|
const context = this.store.decisionContextReason(decision);
|
|
528
540
|
if (context === "work_failed" || context === "work_cancelled") {
|
|
@@ -550,16 +562,18 @@ export class DecisionService {
|
|
|
550
562
|
}
|
|
551
563
|
}
|
|
552
564
|
for (const decision of this.store.allDecisions()) {
|
|
565
|
+
if (securityDecision(decision)) continue;
|
|
553
566
|
if (decision.method === "question" && decision.status === "pending" && decision.bindingState === "live") {
|
|
554
567
|
this.store.reconcileReissuedQuestion(decision.id, this.ports.liveBinding(decision));
|
|
555
568
|
}
|
|
556
569
|
}
|
|
570
|
+
this.store.notifySecurityReceipts();
|
|
557
571
|
const all = this.store.allDecisions();
|
|
558
572
|
for (const decision of all) {
|
|
559
573
|
const previous = this.known.get(decision.id);
|
|
560
574
|
this.known.set(decision.id, decision);
|
|
561
575
|
if (!previous || previous.revision < decision.revision) {
|
|
562
|
-
if (previous && previous.status !== decision.status && !unresolved(decision)) {
|
|
576
|
+
if (!securityDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
|
|
563
577
|
const answer = record(decision.answer) ? decision.answer : {};
|
|
564
578
|
const kind = decision.method === "question" ? "question" : decision.method === "confirm" ? "confirm" : "value";
|
|
565
579
|
const dismissed = decision.status === "closed" ? DISMISSED_REASONS[decision.reason ?? ""] : undefined;
|
|
@@ -580,7 +594,7 @@ export class DecisionService {
|
|
|
580
594
|
}
|
|
581
595
|
const times: number[] = [];
|
|
582
596
|
if (unresolved(decision) && decision.bindingState === "live" && decision.deadlineAt !== null) times.push(decision.deadlineAt);
|
|
583
|
-
if (decision.status === "submitted") {
|
|
597
|
+
if (!securityDecision(decision) && decision.status === "submitted") {
|
|
584
598
|
const operation = this.store.latestDecisionOperation(decision.id);
|
|
585
599
|
times.push(Date.parse(operation?.ts ?? decision.updatedAt) + this.receiptMs);
|
|
586
600
|
}
|
|
@@ -626,7 +640,7 @@ export class DecisionService {
|
|
|
626
640
|
}
|
|
627
641
|
if (!route[2] && req.method === "GET") {
|
|
628
642
|
const decision = this.required(id);
|
|
629
|
-
if (unresolved(decision)) {
|
|
643
|
+
if (!securityDecision(decision) && unresolved(decision)) {
|
|
630
644
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
631
645
|
try {
|
|
632
646
|
await Promise.race([
|
|
@@ -647,12 +661,17 @@ export class DecisionService {
|
|
|
647
661
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
648
662
|
} catch (error) {
|
|
649
663
|
if (error instanceof SecurityCheckError) {
|
|
650
|
-
return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422 });
|
|
664
|
+
return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422, headers: { "Cache-Control": "no-store" } });
|
|
665
|
+
}
|
|
666
|
+
if (error instanceof SecurityRuntimeError) {
|
|
667
|
+
return Response.json({ error: error.code, ...(error.held ? { held: error.held } : {}) }, {
|
|
668
|
+
status: error.code === "held_conflict" ? 409 : 503, headers: { "Cache-Control": "no-store" },
|
|
669
|
+
});
|
|
651
670
|
}
|
|
652
671
|
if (error instanceof DecisionStoreError) {
|
|
653
672
|
return Response.json({
|
|
654
673
|
error: error.code, decision: error.decision ? this.info(error.decision) : null,
|
|
655
|
-
}, { status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409 });
|
|
674
|
+
}, { status: error.code === "decision_not_found" ? 404 : error.code === "security_endpoint_required" || error.code.startsWith("invalid_") ? 400 : 409 });
|
|
656
675
|
}
|
|
657
676
|
if (error instanceof URIError) return Response.json({ error: "invalid_decision" }, { status: 400 });
|
|
658
677
|
this.error(error);
|
package/src/engine/compat.json
CHANGED
package/src/hub.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
|
|
|
14
14
|
import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
|
|
15
15
|
import { readProfile } from "./profile.ts";
|
|
16
16
|
import type { MessageGuard } from "./security-check.ts";
|
|
17
|
+
import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
|
|
17
18
|
import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
19
|
+
import { notificationHistory } from "./notification-history.ts";
|
|
18
20
|
import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
|
|
19
21
|
|
|
20
22
|
export type Broadcast = (envelope: Envelope) => void;
|
|
@@ -64,7 +66,7 @@ export interface HubOptions {
|
|
|
64
66
|
/** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
|
|
65
67
|
usageReport?: () => Promise<string>;
|
|
66
68
|
/** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
|
|
67
|
-
guard?: Pick<MessageGuard, "check" | "hold">;
|
|
69
|
+
guard?: Pick<MessageGuard, "check" | "hold" | "existing">;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
|
|
@@ -371,13 +373,16 @@ export class Hub {
|
|
|
371
373
|
async notificationBaseline(): Promise<void> {
|
|
372
374
|
for (const conv of this.store.listConversations()) {
|
|
373
375
|
if (!conv.threadId) continue;
|
|
374
|
-
const
|
|
376
|
+
const sessionPath = conv.threadId;
|
|
375
377
|
const generation = this.generation;
|
|
376
378
|
const revision = this.options.replies?.revision();
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
379
|
+
const current = () => generation === this.generation &&
|
|
380
|
+
this.store.getConversation(conv.id)?.threadId === conv.threadId;
|
|
381
|
+
await notificationHistory(conv.threadId, (entry) => {
|
|
382
|
+
if (!current()) throw new Error("reply_baseline_discarded");
|
|
383
|
+
if (revision !== undefined) this.options.replies?.observe({ sessionPath, conv: conv.id, title: conv.title }, [entry], revision);
|
|
384
|
+
}, true);
|
|
385
|
+
if (!current()) throw new Error("reply_baseline_discarded");
|
|
381
386
|
}
|
|
382
387
|
}
|
|
383
388
|
|
|
@@ -799,8 +804,16 @@ export class Hub {
|
|
|
799
804
|
else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
|
|
800
805
|
else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
|
|
801
806
|
if (this.options.replies?.settings().mode === "all-replies" && current.threadId) {
|
|
802
|
-
|
|
803
|
-
|
|
807
|
+
if (this.rt(conv.id).sessionId) await this.syncLedger(conv.id);
|
|
808
|
+
else {
|
|
809
|
+
const sessionPath = current.threadId;
|
|
810
|
+
const revision = this.options.replies.revision();
|
|
811
|
+
await notificationHistory(sessionPath, (entry) => {
|
|
812
|
+
if (generation !== this.generation || this.omoStatus !== "ready" ||
|
|
813
|
+
this.store.getConversation(conv.id)?.threadId !== sessionPath) throw new Error("reply_history_discarded");
|
|
814
|
+
this.options.replies?.observe({ sessionPath, conv: conv.id, title: current.title }, [entry], revision);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
804
817
|
}
|
|
805
818
|
// Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
|
|
806
819
|
void this.notifyWorkUpdates(conv.id);
|
|
@@ -815,12 +828,17 @@ export class Hub {
|
|
|
815
828
|
}
|
|
816
829
|
|
|
817
830
|
/** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
|
|
818
|
-
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false): Promise<void> {
|
|
831
|
+
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
|
|
819
832
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
833
|
+
if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
|
|
834
|
+
const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
|
|
835
|
+
snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
|
|
836
|
+
if (this.options.guard?.existing(context)) return;
|
|
820
837
|
const conv = this.store.getConversation(convId);
|
|
821
838
|
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
822
839
|
const body = text.trim();
|
|
823
|
-
const
|
|
840
|
+
const source = structuredClone(src);
|
|
841
|
+
const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
|
|
824
842
|
if (this.options.versionReport && images.length === 0 && references.length === 0 && isVersionCommand(body)) {
|
|
825
843
|
this.emit(convId, "notice", { kind: "version", level: "info", message: await this.options.versionReport() });
|
|
826
844
|
return;
|
|
@@ -837,29 +855,36 @@ export class Hub {
|
|
|
837
855
|
throw new MessageReferenceError("message_reference_images_unavailable");
|
|
838
856
|
}
|
|
839
857
|
if (!body && attached.length === 0 && references.length === 0) return;
|
|
840
|
-
|
|
841
|
-
|
|
858
|
+
const attachmentSnapshot = this.attachments(prompt.images);
|
|
859
|
+
const send = async (receipt?: (ref: SecurityReceipt) => void) => {
|
|
860
|
+
if (receipt && prompt.images.some((name) => !dir || !mediaFile(dir, name))) throw new SecurityRuntimeError("dispatch_failed");
|
|
861
|
+
const input = this.store.acceptMainUser(convId, {
|
|
862
|
+
text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
|
|
863
|
+
...(references.length ? { references } : {}),
|
|
864
|
+
}, receipt);
|
|
865
|
+
this.publishStored(input);
|
|
866
|
+
this.decisions?.refresh();
|
|
867
|
+
const rotationDone = this.rt(convId).rotationDone;
|
|
868
|
+
if (rotationDone) await rotationDone;
|
|
869
|
+
if (this.closeUncertain(convId)) {
|
|
870
|
+
if (this.closeRecoveries.get(convId)?.exhausted) {
|
|
871
|
+
if (receipt) throw new SecurityRuntimeError("dispatch_unknown");
|
|
872
|
+
throw new Error("session_close_recovery_exhausted");
|
|
873
|
+
}
|
|
874
|
+
this.scheduleCloseRecovery(convId);
|
|
875
|
+
this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
|
|
876
|
+
message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
|
|
880
|
+
};
|
|
881
|
+
// The approved closure owns the inspected references, body and image bytes. Never re-resolve reference ids.
|
|
842
882
|
const found = checked ? null : this.options.guard?.check([prompt.text]);
|
|
843
883
|
if (found) {
|
|
844
|
-
this.options.guard!.hold(convId, "message", found,
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
const input = this.store.acceptMainUser(convId, {
|
|
848
|
-
text: body, src, trust, ...(attached.length ? { images: attached } : {}),
|
|
849
|
-
...(references.length ? { references } : {}),
|
|
850
|
-
});
|
|
851
|
-
this.publishStored(input);
|
|
852
|
-
this.decisions?.refresh();
|
|
853
|
-
const rotationDone = this.rt(convId).rotationDone;
|
|
854
|
-
if (rotationDone) await rotationDone;
|
|
855
|
-
if (this.closeUncertain(convId)) {
|
|
856
|
-
if (this.closeRecoveries.get(convId)?.exhausted) throw new Error("session_close_recovery_exhausted");
|
|
857
|
-
this.scheduleCloseRecovery(convId);
|
|
858
|
-
this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
|
|
859
|
-
message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
|
|
884
|
+
this.options.guard!.hold(convId, "message", found, send, body, context);
|
|
860
885
|
return;
|
|
861
886
|
}
|
|
862
|
-
await
|
|
887
|
+
await send();
|
|
863
888
|
}
|
|
864
889
|
|
|
865
890
|
/** Reserve gateway report admission before its HTTP body is parsed. The source
|
|
@@ -1604,7 +1629,7 @@ export class Hub {
|
|
|
1604
1629
|
}
|
|
1605
1630
|
|
|
1606
1631
|
/** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
|
|
1607
|
-
private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false): Promise<void> {
|
|
1632
|
+
private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
|
|
1608
1633
|
const conv = this.store.getConversation(convId);
|
|
1609
1634
|
if (!conv) return;
|
|
1610
1635
|
const rt = this.rt(convId);
|
|
@@ -1622,7 +1647,7 @@ export class Hub {
|
|
|
1622
1647
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
|
|
1623
1648
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
1624
1649
|
const handoff = nativeCommand ? null : this.store.getSessionMeta(convId).handoff;
|
|
1625
|
-
const attached = this.attachments(images);
|
|
1650
|
+
const attached = attachmentSnapshot ?? this.attachments(images);
|
|
1626
1651
|
const text = [body, attached.note].filter(Boolean).join("\n\n");
|
|
1627
1652
|
const context = handoff ? `${handoff}\n\nZGW_WORK_STATE ${JSON.stringify(this.workSummary(convId))}` : null;
|
|
1628
1653
|
const inputId = crypto.randomUUID();
|
|
@@ -1652,6 +1677,7 @@ export class Hub {
|
|
|
1652
1677
|
this.decisions?.refresh();
|
|
1653
1678
|
} catch (error) {
|
|
1654
1679
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
1680
|
+
if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
|
|
1655
1681
|
const message = errorMessage(error);
|
|
1656
1682
|
this.emit(convId, "error", this.continuityError(message));
|
|
1657
1683
|
}
|
package/src/main.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { join, normalize } from "node:path";
|
|
|
4
4
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
5
5
|
import { createSttProvider } from "./adapters/voice/stt.ts";
|
|
6
6
|
import { createTtsProvider, speakableText, splitForTts } from "./adapters/voice/tts.ts";
|
|
7
|
-
import { handleHeld, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
|
|
7
|
+
import { handleHeld, handleSecurityAllowedValues, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SECURITY_ALLOWED_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
|
|
8
8
|
import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
|
|
9
9
|
import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
|
|
10
10
|
import { applyVoiceAgents } from "./voice/agents.ts";
|
|
@@ -150,9 +150,17 @@ let publish: (envelope: Envelope) => void = () => {};
|
|
|
150
150
|
const replyNotifications = new ReplyNotifications<ServerWebSocket<Conn>>(store, (ws, notification) => {
|
|
151
151
|
if (ws.data.deviceId && store.getDevice(ws.data.deviceId)?.status === "approved") ws.send(envelope("reply.notification", notification, notification.conv));
|
|
152
152
|
});
|
|
153
|
+
let securityDecisionRefresh = () => {};
|
|
154
|
+
const securityEvent = (type: string, data: unknown) => publish({
|
|
155
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type, data,
|
|
156
|
+
});
|
|
153
157
|
const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
|
|
154
158
|
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
|
|
155
|
-
}))
|
|
159
|
+
}), { store, state: (state) => {
|
|
160
|
+
securityEvent("security-check.state", state);
|
|
161
|
+
if (state.status === "sent" || state.status === "cancelled") securityEvent("security-check.resolved", { id: state.id, outcome: state.status === "sent" ? "sent" : "dropped" });
|
|
162
|
+
}, allowedUpdated: (revision) => securityEvent("security-check.allowed-values.updated", { revision }),
|
|
163
|
+
decisionUpdated: () => securityDecisionRefresh() });
|
|
156
164
|
const hub = new Hub(
|
|
157
165
|
store,
|
|
158
166
|
dialogs,
|
|
@@ -321,6 +329,7 @@ const decisions = new DecisionService(store, {
|
|
|
321
329
|
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(),
|
|
322
330
|
conv: data.decision.ownerConv, type: "decision.updated", data,
|
|
323
331
|
}), { onHistory: (event) => hub.publishStored(event), guard: messageGuard });
|
|
332
|
+
securityDecisionRefresh = () => decisions.refresh();
|
|
324
333
|
hub.attachDecisions(decisions);
|
|
325
334
|
const summarizer = new SpeechSummarizer(omo, {
|
|
326
335
|
sessionDir: config.omoSessionDir,
|
|
@@ -476,6 +485,8 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
476
485
|
"owner",
|
|
477
486
|
Array.isArray(msg.data.images) ? msg.data.images.filter((n): n is string => typeof n === "string") : [],
|
|
478
487
|
msg.data.references,
|
|
488
|
+
false,
|
|
489
|
+
msg.data.submissionId,
|
|
479
490
|
),
|
|
480
491
|
);
|
|
481
492
|
return;
|
|
@@ -652,9 +663,11 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
652
663
|
if (response) return response;
|
|
653
664
|
}
|
|
654
665
|
if (url.pathname === SECURITY_HELD_PATH || url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) {
|
|
655
|
-
const response = await handleHeld(req, url, messageGuard, (
|
|
656
|
-
|
|
657
|
-
|
|
666
|
+
const response = await handleHeld(req, url, messageGuard, () => {}, { client: trusted ? "web" : "android", deviceId });
|
|
667
|
+
if (response) return response;
|
|
668
|
+
}
|
|
669
|
+
if (url.pathname === SECURITY_ALLOWED_PATH || url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`)) {
|
|
670
|
+
const response = await handleSecurityAllowedValues(req, url, store, (revision) => securityEvent("security-check.allowed-values.updated", { revision }));
|
|
658
671
|
if (response) return response;
|
|
659
672
|
}
|
|
660
673
|
if (url.pathname === SECURITY_CHECK_PATH) {
|
|
@@ -878,8 +891,9 @@ const lanServer = config.lanPort
|
|
|
878
891
|
: null;
|
|
879
892
|
|
|
880
893
|
publish = (e) => {
|
|
881
|
-
|
|
882
|
-
|
|
894
|
+
const sensitiveHeld = e.type === "security-check.held";
|
|
895
|
+
if (!sensitiveHeld && !omoCheckNotifications.allow(e)) return;
|
|
896
|
+
if (!sensitiveHeld) questionNotifications.onEnvelope(e);
|
|
883
897
|
const text = JSON.stringify(e);
|
|
884
898
|
const topic = topicOf(e.type);
|
|
885
899
|
trustedServer.publish(topic, text);
|
|
@@ -926,6 +940,7 @@ function shutdown(): void {
|
|
|
926
940
|
updateChecker.stop();
|
|
927
941
|
clearInterval(updateTick);
|
|
928
942
|
decisions.stop();
|
|
943
|
+
messageGuard.stop();
|
|
929
944
|
watcher.stop();
|
|
930
945
|
work.stop();
|
|
931
946
|
omo.stop();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
|
+
import { nativeReplyEntry, type NativeReplyEntry } from "./reply-notifications.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read only the owned native JSONL, never the disposable resident blobs or an opened native session.
|
|
6
|
+
* The byte snapshot is finite; memory is bounded by a chunk plus the largest JSONL record, not the history.
|
|
7
|
+
*/
|
|
8
|
+
export async function notificationHistory(
|
|
9
|
+
path: string,
|
|
10
|
+
visit: (entry: NativeReplyEntry) => void,
|
|
11
|
+
baseline = false,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
let file;
|
|
14
|
+
try { file = await open(path, "r"); }
|
|
15
|
+
catch (error) {
|
|
16
|
+
// Native buffers setup-only sessions until the first user/assistant message.
|
|
17
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const snapshot = await file.stat();
|
|
22
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
23
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
24
|
+
let offset = 0;
|
|
25
|
+
let tail = "";
|
|
26
|
+
let header = false;
|
|
27
|
+
while (offset < snapshot.size) {
|
|
28
|
+
const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, snapshot.size - offset), offset);
|
|
29
|
+
if (bytesRead === 0) throw new Error("reply_history_truncated");
|
|
30
|
+
offset += bytesRead;
|
|
31
|
+
tail += decoder.decode(chunk.subarray(0, bytesRead), { stream: true });
|
|
32
|
+
let newline: number;
|
|
33
|
+
while ((newline = tail.indexOf("\n")) !== -1) {
|
|
34
|
+
const line = tail.slice(0, newline);
|
|
35
|
+
tail = tail.slice(newline + 1);
|
|
36
|
+
const value: unknown = JSON.parse(line);
|
|
37
|
+
const entry = nativeReplyEntry(value);
|
|
38
|
+
if (!entry) throw new Error("reply_history_invalid_entry");
|
|
39
|
+
if (!header) {
|
|
40
|
+
if (entry.type !== "session") throw new Error("reply_history_invalid_header");
|
|
41
|
+
header = true;
|
|
42
|
+
} else {
|
|
43
|
+
if (entry.type === "session") throw new Error("reply_history_duplicate_header");
|
|
44
|
+
visit(baseline ? { id: entry.id, type: entry.type } : entry);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
tail += decoder.decode();
|
|
49
|
+
if (!header || tail.length !== 0) throw new Error("reply_history_incomplete");
|
|
50
|
+
const current = await stat(path);
|
|
51
|
+
if (current.dev !== snapshot.dev || current.ino !== snapshot.ino || current.size < snapshot.size ||
|
|
52
|
+
(current.size === snapshot.size && current.mtimeMs !== snapshot.mtimeMs)) throw new Error("reply_history_replaced");
|
|
53
|
+
} finally {
|
|
54
|
+
await file.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type DecisionAnswerRequest = { readonly expectedRevision: number; readonl
|
|
|
85
85
|
|
|
86
86
|
/** HTTP/event DTOs are separate from storage records; payload retains the existing action-card structure. */
|
|
87
87
|
export interface DecisionInfo {
|
|
88
|
+
readonly security?: import("./security-allow.ts").HeldState;
|
|
88
89
|
readonly id: string;
|
|
89
90
|
readonly revision: number;
|
|
90
91
|
readonly ownerConv: string;
|
|
@@ -151,7 +152,7 @@ export type ClientMessage =
|
|
|
151
152
|
| { type: "client.presence"; data: { visible: boolean } }
|
|
152
153
|
| { type: "history.get"; conv: string; before?: number; after?: number; limit?: number }
|
|
153
154
|
| { type: "conversation.create"; data: { title: string } }
|
|
154
|
-
| { type: "input.text"; conv: string; data: { text: string; images?: string[]; references?: number[] } }
|
|
155
|
+
| { type: "input.text"; conv: string; data: { text: string; images?: string[]; references?: number[]; submissionId?: string } }
|
|
155
156
|
| { type: "input.action"; conv: string; data: ActionAnswerData }
|
|
156
157
|
| { type: "input.interrupt"; conv: string; data: Record<string, never> }
|
|
157
158
|
| { type: "conversation.rotate"; conv: string }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Runtime-only contract. Repository and release scanners never import this module. */
|
|
2
|
+
export type HeldStatus = "pending" | "processing" | "sent" | "cancelled" | "failed" | "unconfirmed";
|
|
3
|
+
export type SecurityErrorCode = "allowlist_persist_failed" | "state_persist_failed" | "payload_lost" | "dispatch_failed" | "dispatch_unknown" | "original_decision_conflict";
|
|
4
|
+
export interface HeldState {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly decisionId: string;
|
|
7
|
+
readonly revision: number;
|
|
8
|
+
readonly conv: string;
|
|
9
|
+
readonly source: "message" | "answer";
|
|
10
|
+
readonly createdAt: string;
|
|
11
|
+
readonly originalDecisionId: string | null;
|
|
12
|
+
readonly status: HeldStatus;
|
|
13
|
+
readonly submissionId: string | null;
|
|
14
|
+
readonly remembered: boolean;
|
|
15
|
+
readonly allowlistRevision: number;
|
|
16
|
+
readonly errorCode: SecurityErrorCode | null;
|
|
17
|
+
}
|
|
18
|
+
export interface SecurityApprover {
|
|
19
|
+
readonly client: "web" | "android";
|
|
20
|
+
readonly deviceId: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface AllowedValue {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly value: string;
|
|
25
|
+
readonly kind: string;
|
|
26
|
+
readonly label: string;
|
|
27
|
+
readonly approvedAt: string;
|
|
28
|
+
readonly approvedBy: SecurityApprover;
|
|
29
|
+
readonly heldId: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SecurityIntent {
|
|
32
|
+
readonly send: boolean;
|
|
33
|
+
readonly remember: boolean;
|
|
34
|
+
readonly expectedRevision: number | null;
|
|
35
|
+
readonly submissionId: string | null;
|
|
36
|
+
}
|
|
37
|
+
export type SecurityReceipt =
|
|
38
|
+
| { readonly kind: "message"; readonly conv: string; readonly messageSeq: number }
|
|
39
|
+
| { readonly kind: "answer"; readonly decisionId: string; readonly submissionId: string };
|
|
40
|
+
export interface SecurityHoldRecord {
|
|
41
|
+
readonly state: HeldState;
|
|
42
|
+
readonly key: string | null;
|
|
43
|
+
readonly intent: SecurityIntent | null;
|
|
44
|
+
readonly dispatchReserved: boolean;
|
|
45
|
+
readonly receipt: SecurityReceipt | null;
|
|
46
|
+
}
|
|
47
|
+
export class SecurityRuntimeError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly code: "held_conflict" | "allowlist_conflict" | SecurityErrorCode,
|
|
50
|
+
readonly held?: HeldState,
|
|
51
|
+
readonly revision?: number,
|
|
52
|
+
) { super(code); }
|
|
53
|
+
}
|
|
54
|
+
export const securityDecision = (decision: { readonly payload: unknown }): boolean =>
|
|
55
|
+
typeof decision.payload === "object" && decision.payload !== null &&
|
|
56
|
+
"kind" in decision.payload && decision.payload.kind === "security-check";
|
|
57
|
+
|
|
58
|
+
/** A delimiter is meaningful only if its matching quote can be proved. No decoding or normalization. */
|
|
59
|
+
function quotedEnd(text: string, start: number, quote: string): number | null {
|
|
60
|
+
for (let i = start; i < text.length; i++) {
|
|
61
|
+
if (text[i] === "\\") { i++; continue; }
|
|
62
|
+
if (text[i] === quote) return i;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Expand an existing detector hit; this function never decides whether a hit exists. */
|
|
68
|
+
export function canonicalSecurityValue(text: string, rule: string, start: number, end: number): string {
|
|
69
|
+
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
|
70
|
+
const nextLine = text.indexOf("\n", end);
|
|
71
|
+
const lineEnd = nextLine < 0 ? text.length : nextLine;
|
|
72
|
+
// A PEM or SSH record owns every nested hit, including an address in its comment.
|
|
73
|
+
for (const match of text.matchAll(/-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/g)) {
|
|
74
|
+
const begin = match.index;
|
|
75
|
+
const closing = `-----END ${match[1]}-----`;
|
|
76
|
+
const at = text.indexOf(closing, begin + match[0].length);
|
|
77
|
+
const finish = at < 0 ? text.length : at + closing.length;
|
|
78
|
+
if (start >= begin && start < finish) return text.slice(begin, finish);
|
|
79
|
+
}
|
|
80
|
+
const line = text.slice(lineStart, lineEnd).replace(/\r$/, "");
|
|
81
|
+
if (/(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-[A-Za-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)[ \t]+[A-Za-z0-9+/]{20,}/.test(line)) return line;
|
|
82
|
+
// Locate quoted units and keep their original escape bytes.
|
|
83
|
+
let enclosing: { start: number; end: number | null } | undefined;
|
|
84
|
+
for (let i = lineStart; i < start; i++) {
|
|
85
|
+
const ch = text[i];
|
|
86
|
+
if (ch !== "'" && ch !== '"' && ch !== "`") continue;
|
|
87
|
+
const finish = quotedEnd(text, i + 1, ch);
|
|
88
|
+
if (finish === null || finish >= end) { enclosing = { start: i + 1, end: finish }; break; }
|
|
89
|
+
i = finish;
|
|
90
|
+
}
|
|
91
|
+
if (enclosing?.end === null) return text.slice(enclosing.start);
|
|
92
|
+
const header = /\b(?:authorization["']?\s*[:=]\s*["']?(?:Bearer|Basic)\s+|(?:set-cookie|cookie)["']?\s*[:=]\s*["']?)/i.exec(line);
|
|
93
|
+
if (header) {
|
|
94
|
+
const valueStart = lineStart + header.index + header[0].length;
|
|
95
|
+
if (start >= valueStart) return text.slice(valueStart, enclosing?.end ?? lineEnd).replace(/\r$/, "");
|
|
96
|
+
}
|
|
97
|
+
if (enclosing && enclosing.end !== null && /[:=]\s*$/.test(text.slice(lineStart, enclosing.start - 1))) {
|
|
98
|
+
return text.slice(enclosing.start, enclosing.end);
|
|
99
|
+
}
|
|
100
|
+
// URLs take precedence over addresses, token fragments and assignment captures inside them.
|
|
101
|
+
for (const match of text.matchAll(/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]+/g)) {
|
|
102
|
+
if (start >= match.index && start < match.index + match[0].length) {
|
|
103
|
+
if (enclosing && enclosing.end !== null) return text.slice(match.index, enclosing.end);
|
|
104
|
+
return match[0];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// A path owns nested host/token captures. Quoted paths may contain spaces.
|
|
108
|
+
for (const match of text.matchAll(/\/(?:Users|home)\/[^\s"'`<>]*/g)) {
|
|
109
|
+
const finish = enclosing && enclosing.end !== null && match.index >= enclosing.start ?
|
|
110
|
+
enclosing.end : match.index + match[0].length;
|
|
111
|
+
if (start >= match.index && start < finish) return text.slice(match.index, finish);
|
|
112
|
+
}
|
|
113
|
+
if (["high-entropy-assignment", "credential-assignment", "device-serial", "authorization", "cookie-header"].includes(rule)) {
|
|
114
|
+
if (enclosing && enclosing.end !== null) return text.slice(start, enclosing.end);
|
|
115
|
+
if (rule === "authorization" || rule === "cookie-header") return text.slice(start, lineEnd).replace(/\r$/, "");
|
|
116
|
+
let finish = end;
|
|
117
|
+
while (finish < text.length && !/[\s,;}]/.test(text[finish] ?? "")) finish++;
|
|
118
|
+
return text.slice(start, finish);
|
|
119
|
+
}
|
|
120
|
+
if (["github-token", "npm-token", "anthropic-key", "openai-key", "notion-token", "slack-token", "aws-access-key", "gcp-api-key", "tailscale-key", "jwt"].includes(rule)) {
|
|
121
|
+
let finish = end;
|
|
122
|
+
while (finish < text.length && /[A-Za-z0-9_+/.=-]/.test(text[finish] ?? "")) finish++;
|
|
123
|
+
return text.slice(start, finish);
|
|
124
|
+
}
|
|
125
|
+
return text.slice(start, end);
|
|
126
|
+
}
|