@oyasmi/pipiclaw 0.5.8 → 0.6.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/README.md +39 -3
- package/dist/agent/channel-runner.d.ts +5 -0
- package/dist/agent/channel-runner.js +59 -15
- package/dist/agent/prompt-builder.js +6 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/memory/consolidation.js +11 -2
- package/dist/memory/session.js +2 -2
- package/dist/memory/sidecar-worker.d.ts +1 -0
- package/dist/memory/sidecar-worker.js +56 -1
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +2 -0
- package/dist/runtime/bootstrap.d.ts +2 -1
- package/dist/runtime/bootstrap.js +74 -23
- package/dist/runtime/delivery.js +56 -5
- package/dist/runtime/dingtalk.d.ts +2 -0
- package/dist/runtime/dingtalk.js +14 -7
- package/dist/runtime/events.d.ts +3 -0
- package/dist/runtime/events.js +30 -5
- package/dist/security/command-guard.js +4 -0
- package/dist/security/config.d.ts +6 -0
- package/dist/security/config.js +57 -6
- package/dist/security/network.d.ts +28 -0
- package/dist/security/network.js +246 -0
- package/dist/security/path-guard.js +4 -0
- package/dist/security/platform.d.ts +1 -0
- package/dist/security/platform.js +3 -0
- package/dist/security/types.d.ts +16 -1
- package/dist/settings.d.ts +4 -1
- package/dist/settings.js +31 -6
- package/dist/shared/config-diagnostics.d.ts +7 -0
- package/dist/shared/config-diagnostics.js +3 -0
- package/dist/subagents/discovery.d.ts +1 -1
- package/dist/subagents/discovery.js +1 -1
- package/dist/subagents/tool.d.ts +2 -0
- package/dist/subagents/tool.js +24 -2
- package/dist/tools/config.d.ts +37 -0
- package/dist/tools/config.js +170 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +23 -1
- package/dist/tools/web-fetch.d.ts +17 -0
- package/dist/tools/web-fetch.js +29 -0
- package/dist/tools/web-search.d.ts +16 -0
- package/dist/tools/web-search.js +29 -0
- package/dist/web/client.d.ts +41 -0
- package/dist/web/client.js +193 -0
- package/dist/web/config.d.ts +19 -0
- package/dist/web/config.js +35 -0
- package/dist/web/extract.d.ts +7 -0
- package/dist/web/extract.js +122 -0
- package/dist/web/fetch.d.ts +23 -0
- package/dist/web/fetch.js +150 -0
- package/dist/web/format.d.ts +21 -0
- package/dist/web/format.js +38 -0
- package/dist/web/search-providers.d.ts +15 -0
- package/dist/web/search-providers.js +199 -0
- package/dist/web/search.d.ts +19 -0
- package/dist/web/search.js +52 -0
- package/package.json +9 -2
package/dist/runtime/delivery.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as log from "../log.js";
|
|
2
2
|
const MIN_UPDATE_INTERVAL_MS = 800;
|
|
3
|
+
const NO_CONTENT = "";
|
|
3
4
|
class ChannelDeliveryController {
|
|
4
5
|
constructor(event, bot, store) {
|
|
5
6
|
this.event = event;
|
|
@@ -12,9 +13,12 @@ class ChannelDeliveryController {
|
|
|
12
13
|
this.running = false;
|
|
13
14
|
this.closed = false;
|
|
14
15
|
this.finalResponseDelivered = false;
|
|
16
|
+
this.cardWarmupScheduled = false;
|
|
17
|
+
this.cardWarmupTriggered = false;
|
|
15
18
|
this.progressWindowStartedAt = 0;
|
|
16
19
|
this.lastDeliveredAt = 0;
|
|
17
20
|
this.timer = null;
|
|
21
|
+
this.cardWarmupTimer = null;
|
|
18
22
|
this.flushWaiters = [];
|
|
19
23
|
}
|
|
20
24
|
buildContext() {
|
|
@@ -37,19 +41,57 @@ class ChannelDeliveryController {
|
|
|
37
41
|
setTyping: async (_isTyping) => { },
|
|
38
42
|
setWorking: async (_working) => { },
|
|
39
43
|
deleteMessage: async () => this.silence(),
|
|
44
|
+
primeCard: (delayMs) => this.primeCard(delayMs),
|
|
40
45
|
flush: async () => this.flush(),
|
|
41
46
|
close: async () => this.close(),
|
|
42
47
|
};
|
|
43
48
|
}
|
|
49
|
+
primeCard(delayMs) {
|
|
50
|
+
if (this.closed || this.finalResponseDelivered || this.cardWarmupScheduled || this.cardWarmupTriggered) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.cardWarmupScheduled = true;
|
|
54
|
+
this.cardWarmupTimer = setTimeout(() => {
|
|
55
|
+
this.cardWarmupScheduled = false;
|
|
56
|
+
this.cardWarmupTimer = null;
|
|
57
|
+
void this.triggerCardWarmup();
|
|
58
|
+
}, Math.max(0, delayMs));
|
|
59
|
+
}
|
|
60
|
+
async triggerCardWarmup() {
|
|
61
|
+
if (this.closed || this.finalResponseDelivered || this.desiredRevision > 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.cardWarmupTriggered = true;
|
|
65
|
+
try {
|
|
66
|
+
await this.bot.ensureCard(this.event.channelId);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
log.logWarning(`[${this.event.channelId}] Failed to warm AI card`, err instanceof Error ? err.message : String(err));
|
|
70
|
+
this.bot.discardCard(this.event.channelId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
clearCardWarmup() {
|
|
74
|
+
this.cardWarmupScheduled = false;
|
|
75
|
+
if (this.cardWarmupTimer) {
|
|
76
|
+
clearTimeout(this.cardWarmupTimer);
|
|
77
|
+
this.cardWarmupTimer = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
archiveBotResponse(text) {
|
|
81
|
+
void this.store.logBotResponse(this.event.channelId, text, Date.now().toString()).catch((err) => {
|
|
82
|
+
log.logWarning(`[${this.event.channelId}] Failed to archive bot response`, err instanceof Error ? err.message : String(err));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
44
85
|
async appendProgress(text, shouldLog) {
|
|
45
86
|
if (this.closed || this.finalResponseDelivered || !text.trim())
|
|
46
87
|
return;
|
|
88
|
+
this.clearCardWarmup();
|
|
47
89
|
this.progressText = this.progressText ? `${this.progressText}\n\n${text}` : text;
|
|
48
90
|
if (this.progressWindowStartedAt === 0) {
|
|
49
91
|
this.progressWindowStartedAt = Date.now();
|
|
50
92
|
}
|
|
51
93
|
if (shouldLog) {
|
|
52
|
-
|
|
94
|
+
this.archiveBotResponse(text);
|
|
53
95
|
}
|
|
54
96
|
this.mode = "progress";
|
|
55
97
|
this.bumpRevision(false);
|
|
@@ -57,8 +99,9 @@ class ChannelDeliveryController {
|
|
|
57
99
|
async sendFinal(text, shouldLog) {
|
|
58
100
|
if (this.closed || this.finalResponseDelivered)
|
|
59
101
|
return this.finalResponseDelivered;
|
|
102
|
+
this.clearCardWarmup();
|
|
60
103
|
if (shouldLog) {
|
|
61
|
-
|
|
104
|
+
this.archiveBotResponse(text);
|
|
62
105
|
}
|
|
63
106
|
const delivered = await this.bot.sendPlain(this.event.channelId, text);
|
|
64
107
|
if (!delivered) {
|
|
@@ -72,6 +115,7 @@ class ChannelDeliveryController {
|
|
|
72
115
|
async replaceWithFinal(text) {
|
|
73
116
|
if (this.closed || this.finalResponseDelivered)
|
|
74
117
|
return;
|
|
118
|
+
this.clearCardWarmup();
|
|
75
119
|
this.progressText = text;
|
|
76
120
|
this.mode = "finalize-with-fallback";
|
|
77
121
|
this.bumpRevision(true);
|
|
@@ -79,6 +123,7 @@ class ChannelDeliveryController {
|
|
|
79
123
|
async silence() {
|
|
80
124
|
if (this.closed)
|
|
81
125
|
return;
|
|
126
|
+
this.clearCardWarmup();
|
|
82
127
|
this.finalResponseDelivered = true;
|
|
83
128
|
this.mode = "silent";
|
|
84
129
|
this.bumpRevision(true);
|
|
@@ -138,8 +183,8 @@ class ChannelDeliveryController {
|
|
|
138
183
|
}
|
|
139
184
|
}
|
|
140
185
|
else if (mode === "finalize-existing") {
|
|
141
|
-
if (content) {
|
|
142
|
-
touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, this.progressText);
|
|
186
|
+
if (content || this.cardWarmupTriggered) {
|
|
187
|
+
touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, content ? this.progressText : NO_CONTENT);
|
|
143
188
|
if (!touchedRemote) {
|
|
144
189
|
this.bot.discardCard(this.event.channelId);
|
|
145
190
|
}
|
|
@@ -160,7 +205,12 @@ class ChannelDeliveryController {
|
|
|
160
205
|
}
|
|
161
206
|
}
|
|
162
207
|
else if (mode === "silent") {
|
|
163
|
-
|
|
208
|
+
if (this.cardWarmupTriggered) {
|
|
209
|
+
touchedRemote = await this.bot.finalizeExistingCard(this.event.channelId, NO_CONTENT);
|
|
210
|
+
}
|
|
211
|
+
if (!touchedRemote) {
|
|
212
|
+
this.bot.discardCard(this.event.channelId);
|
|
213
|
+
}
|
|
164
214
|
}
|
|
165
215
|
}
|
|
166
216
|
catch (err) {
|
|
@@ -209,6 +259,7 @@ class ChannelDeliveryController {
|
|
|
209
259
|
return;
|
|
210
260
|
}
|
|
211
261
|
this.closed = true;
|
|
262
|
+
this.clearCardWarmup();
|
|
212
263
|
await this.flush();
|
|
213
264
|
}
|
|
214
265
|
}
|
|
@@ -34,6 +34,7 @@ export interface DingTalkContext {
|
|
|
34
34
|
setTyping: (isTyping: boolean) => Promise<void>;
|
|
35
35
|
setWorking: (working: boolean) => Promise<void>;
|
|
36
36
|
deleteMessage: () => Promise<void>;
|
|
37
|
+
primeCard: (delayMs: number) => void;
|
|
37
38
|
flush: () => Promise<void>;
|
|
38
39
|
close: () => Promise<void>;
|
|
39
40
|
}
|
|
@@ -61,6 +62,7 @@ export declare class DingTalkBot {
|
|
|
61
62
|
private isReconnecting;
|
|
62
63
|
private isStopped;
|
|
63
64
|
private reconnectAttempts;
|
|
65
|
+
private hasReportedReady;
|
|
64
66
|
private processedIds;
|
|
65
67
|
private processedIdsOrder;
|
|
66
68
|
constructor(handler: DingTalkHandler, config: DingTalkConfig);
|
package/dist/runtime/dingtalk.js
CHANGED
|
@@ -78,6 +78,7 @@ export class DingTalkBot {
|
|
|
78
78
|
this.isReconnecting = false;
|
|
79
79
|
this.isStopped = false;
|
|
80
80
|
this.reconnectAttempts = 0;
|
|
81
|
+
this.hasReportedReady = false;
|
|
81
82
|
// Deduplication cache (Set for O(1) lookup, order array for FIFO eviction)
|
|
82
83
|
this.processedIds = new Set();
|
|
83
84
|
this.processedIdsOrder = [];
|
|
@@ -171,9 +172,6 @@ export class DingTalkBot {
|
|
|
171
172
|
log.logWarning("DingTalk: cardTemplateId not configured — AI Card streaming will not work");
|
|
172
173
|
}
|
|
173
174
|
log.logInfo(`DingTalk: initializing stream (clientId=${this.config.clientId.substring(0, 8)}…)`);
|
|
174
|
-
if (process.env.DINGTALK_FORCE_PROXY !== "true") {
|
|
175
|
-
axios.defaults.proxy = false;
|
|
176
|
-
}
|
|
177
175
|
this.clearAllTimers();
|
|
178
176
|
this.client = new DWClient({
|
|
179
177
|
clientId: this.config.clientId,
|
|
@@ -183,8 +181,10 @@ export class DingTalkBot {
|
|
|
183
181
|
this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
|
|
184
182
|
return this.handleRawMessage(msg);
|
|
185
183
|
});
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
const connected = await this.doReconnect(true); // Initial connection
|
|
185
|
+
if (!connected) {
|
|
186
|
+
log.logWarning("DingTalk: initial stream connection not ready yet; retrying in background");
|
|
187
|
+
}
|
|
188
188
|
}
|
|
189
189
|
handleRawMessage(msg) {
|
|
190
190
|
// 1. Immediate ACK
|
|
@@ -216,16 +216,17 @@ export class DingTalkBot {
|
|
|
216
216
|
}
|
|
217
217
|
async doReconnect(immediate = false) {
|
|
218
218
|
if (this.isReconnecting || this.isStopped || !this.client)
|
|
219
|
-
return;
|
|
219
|
+
return false;
|
|
220
220
|
this.isReconnecting = true;
|
|
221
221
|
let connectionFailed = false;
|
|
222
|
+
let connected = false;
|
|
222
223
|
if (!immediate && this.reconnectAttempts > 0) {
|
|
223
224
|
const delay = Math.min(1000 * 2 ** this.reconnectAttempts + Math.random() * 1000, 30000);
|
|
224
225
|
log.logInfo(`DingTalk: waiting ${Math.round(delay / 1000)}s before reconnecting...`);
|
|
225
226
|
await this.waitForDelay(delay);
|
|
226
227
|
if (this.isStopped || !this.client) {
|
|
227
228
|
this.isReconnecting = false;
|
|
228
|
-
return;
|
|
229
|
+
return false;
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
try {
|
|
@@ -237,6 +238,11 @@ export class DingTalkBot {
|
|
|
237
238
|
this.lastSocketAvailableTime = Date.now();
|
|
238
239
|
this.reconnectAttempts = 0; // Success, reset backoff
|
|
239
240
|
log.logInfo("DingTalk: connected to stream.");
|
|
241
|
+
if (!this.hasReportedReady) {
|
|
242
|
+
log.logConnected();
|
|
243
|
+
this.hasReportedReady = true;
|
|
244
|
+
}
|
|
245
|
+
connected = true;
|
|
240
246
|
// Setup keep alive
|
|
241
247
|
this.clearKeepAliveTimer();
|
|
242
248
|
this.keepAliveTimer = this.setTrackedInterval(() => {
|
|
@@ -294,6 +300,7 @@ export class DingTalkBot {
|
|
|
294
300
|
if (connectionFailed && !this.isStopped) {
|
|
295
301
|
this.scheduleReconnect(0, false);
|
|
296
302
|
}
|
|
303
|
+
return connected;
|
|
297
304
|
}
|
|
298
305
|
async stop() {
|
|
299
306
|
log.logInfo("DingTalk: stopping bot");
|
package/dist/runtime/events.d.ts
CHANGED
package/dist/runtime/events.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cron } from "croner";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "fs";
|
|
3
3
|
import { readFile } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import * as log from "../log.js";
|
|
@@ -128,10 +128,11 @@ export class EventsWatcher {
|
|
|
128
128
|
}
|
|
129
129
|
if (!event) {
|
|
130
130
|
log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
|
|
131
|
-
this.
|
|
131
|
+
this.markInvalid(filename, lastError?.message ?? "Unknown event parse error");
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
134
|
this.knownFiles.add(filename);
|
|
135
|
+
this.clearInvalidMarker(filename);
|
|
135
136
|
switch (event.type) {
|
|
136
137
|
case "immediate":
|
|
137
138
|
this.handleImmediate(filename, event);
|
|
@@ -196,7 +197,7 @@ export class EventsWatcher {
|
|
|
196
197
|
const now = Date.now();
|
|
197
198
|
if (!Number.isFinite(atTime)) {
|
|
198
199
|
log.logWarning(`Invalid one-shot time for ${filename}: ${event.at}`);
|
|
199
|
-
this.
|
|
200
|
+
this.markInvalid(filename, `Invalid one-shot time: ${event.at}`);
|
|
200
201
|
return;
|
|
201
202
|
}
|
|
202
203
|
if (atTime <= now) {
|
|
@@ -207,7 +208,7 @@ export class EventsWatcher {
|
|
|
207
208
|
const delay = atTime - now;
|
|
208
209
|
if (delay > MAX_TIMEOUT_MS) {
|
|
209
210
|
log.logWarning(`One-shot event exceeds maximum supported delay for ${filename}: ${event.at}. Use a periodic cron event instead.`);
|
|
210
|
-
this.
|
|
211
|
+
this.markInvalid(filename, `One-shot event exceeds maximum supported delay: ${event.at}`);
|
|
211
212
|
return;
|
|
212
213
|
}
|
|
213
214
|
log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
|
|
@@ -230,7 +231,7 @@ export class EventsWatcher {
|
|
|
230
231
|
}
|
|
231
232
|
catch (err) {
|
|
232
233
|
log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
|
|
233
|
-
this.
|
|
234
|
+
this.markInvalid(filename, `Invalid cron schedule: ${event.schedule}\n${String(err)}`);
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
execute(filename, event, deleteAfter = true) {
|
|
@@ -279,8 +280,32 @@ export class EventsWatcher {
|
|
|
279
280
|
log.logWarning(`Failed to delete event file: ${filename}`, String(err));
|
|
280
281
|
}
|
|
281
282
|
}
|
|
283
|
+
this.clearInvalidMarker(filename);
|
|
282
284
|
this.knownFiles.delete(filename);
|
|
283
285
|
}
|
|
286
|
+
getInvalidMarkerPath(filename) {
|
|
287
|
+
return join(this.eventsDir, `${filename}.error.txt`);
|
|
288
|
+
}
|
|
289
|
+
markInvalid(filename, message) {
|
|
290
|
+
try {
|
|
291
|
+
writeFileSync(this.getInvalidMarkerPath(filename), [`timestamp: ${new Date().toISOString()}`, `file: ${filename}`, "", message.trim()].join("\n"), "utf-8");
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
log.logWarning(`Failed to write event error marker: ${filename}`, String(err));
|
|
295
|
+
}
|
|
296
|
+
this.knownFiles.add(filename);
|
|
297
|
+
}
|
|
298
|
+
clearInvalidMarker(filename) {
|
|
299
|
+
const markerPath = this.getInvalidMarkerPath(filename);
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(markerPath);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
|
305
|
+
log.logWarning(`Failed to delete event error marker: ${filename}`, String(err));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
284
309
|
sleep(ms) {
|
|
285
310
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
286
311
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
+
import { isWindowsPlatform } from "./platform.js";
|
|
2
3
|
const WHITESPACE = /\s+/;
|
|
3
4
|
function stripNullAndNormalize(text) {
|
|
4
5
|
return text.replace(/\0/g, "").normalize("NFKC");
|
|
@@ -402,6 +403,9 @@ export function guardCommand(command, config) {
|
|
|
402
403
|
if (!config.enabled) {
|
|
403
404
|
return { allowed: true };
|
|
404
405
|
}
|
|
406
|
+
if (isWindowsPlatform()) {
|
|
407
|
+
return { allowed: true };
|
|
408
|
+
}
|
|
405
409
|
const atoms = splitCommandChain(command);
|
|
406
410
|
const normalizedWhole = stripNullAndNormalize(command);
|
|
407
411
|
for (const allowPattern of config.allowPatterns) {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import type { ConfigDiagnostic } from "../shared/config-diagnostics.js";
|
|
1
2
|
import type { SecurityConfig } from "./types.js";
|
|
3
|
+
export interface LoadedSecurityConfig {
|
|
4
|
+
config: SecurityConfig;
|
|
5
|
+
diagnostics: ConfigDiagnostic[];
|
|
6
|
+
}
|
|
2
7
|
export declare const DEFAULT_SECURITY_CONFIG: SecurityConfig;
|
|
3
8
|
export declare function getSecurityConfigPath(appHomeDir?: string): string;
|
|
9
|
+
export declare function loadSecurityConfigWithDiagnostics(appHomeDir?: string): LoadedSecurityConfig;
|
|
4
10
|
export declare function loadSecurityConfig(appHomeDir?: string): SecurityConfig;
|
package/dist/security/config.js
CHANGED
|
@@ -18,6 +18,12 @@ export const DEFAULT_SECURITY_CONFIG = {
|
|
|
18
18
|
writeDeny: [],
|
|
19
19
|
resolveSymlinks: true,
|
|
20
20
|
},
|
|
21
|
+
networkGuard: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
allowedCidrs: [],
|
|
24
|
+
allowedHosts: [],
|
|
25
|
+
maxRedirects: 5,
|
|
26
|
+
},
|
|
21
27
|
audit: {
|
|
22
28
|
logBlocked: true,
|
|
23
29
|
},
|
|
@@ -28,13 +34,30 @@ function asStringArray(value) {
|
|
|
28
34
|
function asOptionalString(value) {
|
|
29
35
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
30
36
|
}
|
|
31
|
-
function
|
|
37
|
+
function pushInvalidSecurityDiagnostic(diagnostics, configPath, field, message) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
source: "security",
|
|
40
|
+
path: configPath,
|
|
41
|
+
severity: "warning",
|
|
42
|
+
message: `${field}: ${message}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function mergeSecurityConfig(source, configPath, diagnostics) {
|
|
32
46
|
if (!isRecord(source)) {
|
|
47
|
+
pushInvalidSecurityDiagnostic(diagnostics, configPath, "root", "expected a JSON object; using defaults");
|
|
33
48
|
return DEFAULT_SECURITY_CONFIG;
|
|
34
49
|
}
|
|
35
50
|
const commandGuard = isRecord(source.commandGuard) ? source.commandGuard : {};
|
|
36
51
|
const pathGuard = isRecord(source.pathGuard) ? source.pathGuard : {};
|
|
52
|
+
const networkGuard = isRecord(source.networkGuard) ? source.networkGuard : {};
|
|
37
53
|
const audit = isRecord(source.audit) ? source.audit : {};
|
|
54
|
+
if (networkGuard.maxRedirects !== undefined) {
|
|
55
|
+
const maxRedirects = networkGuard.maxRedirects;
|
|
56
|
+
const isValidMaxRedirects = typeof maxRedirects === "number" && Number.isFinite(maxRedirects) && maxRedirects > 0;
|
|
57
|
+
if (!isValidMaxRedirects) {
|
|
58
|
+
pushInvalidSecurityDiagnostic(diagnostics, configPath, "networkGuard.maxRedirects", "expected a positive integer; using default");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
38
61
|
return {
|
|
39
62
|
enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_SECURITY_CONFIG.enabled,
|
|
40
63
|
commandGuard: {
|
|
@@ -57,6 +80,18 @@ function mergeSecurityConfig(source) {
|
|
|
57
80
|
? pathGuard.resolveSymlinks
|
|
58
81
|
: DEFAULT_SECURITY_CONFIG.pathGuard.resolveSymlinks,
|
|
59
82
|
},
|
|
83
|
+
networkGuard: {
|
|
84
|
+
enabled: typeof networkGuard.enabled === "boolean"
|
|
85
|
+
? networkGuard.enabled
|
|
86
|
+
: DEFAULT_SECURITY_CONFIG.networkGuard.enabled,
|
|
87
|
+
allowedCidrs: asStringArray(networkGuard.allowedCidrs),
|
|
88
|
+
allowedHosts: asStringArray(networkGuard.allowedHosts),
|
|
89
|
+
maxRedirects: typeof networkGuard.maxRedirects === "number" &&
|
|
90
|
+
Number.isFinite(networkGuard.maxRedirects) &&
|
|
91
|
+
networkGuard.maxRedirects > 0
|
|
92
|
+
? Math.floor(networkGuard.maxRedirects)
|
|
93
|
+
: DEFAULT_SECURITY_CONFIG.networkGuard.maxRedirects,
|
|
94
|
+
},
|
|
60
95
|
audit: {
|
|
61
96
|
logBlocked: typeof audit.logBlocked === "boolean" ? audit.logBlocked : DEFAULT_SECURITY_CONFIG.audit.logBlocked,
|
|
62
97
|
logFile: asOptionalString(audit.logFile),
|
|
@@ -66,17 +101,33 @@ function mergeSecurityConfig(source) {
|
|
|
66
101
|
export function getSecurityConfigPath(appHomeDir = APP_HOME_DIR) {
|
|
67
102
|
return join(appHomeDir, "security.json");
|
|
68
103
|
}
|
|
69
|
-
export function
|
|
104
|
+
export function loadSecurityConfigWithDiagnostics(appHomeDir = APP_HOME_DIR) {
|
|
70
105
|
const configPath = getSecurityConfigPath(appHomeDir);
|
|
71
106
|
if (!existsSync(configPath)) {
|
|
72
|
-
return DEFAULT_SECURITY_CONFIG;
|
|
107
|
+
return { config: DEFAULT_SECURITY_CONFIG, diagnostics: [] };
|
|
73
108
|
}
|
|
74
109
|
try {
|
|
75
110
|
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
76
|
-
|
|
111
|
+
const diagnostics = [];
|
|
112
|
+
return {
|
|
113
|
+
config: mergeSecurityConfig(raw, configPath, diagnostics),
|
|
114
|
+
diagnostics,
|
|
115
|
+
};
|
|
77
116
|
}
|
|
78
117
|
catch (error) {
|
|
79
|
-
|
|
80
|
-
|
|
118
|
+
return {
|
|
119
|
+
config: DEFAULT_SECURITY_CONFIG,
|
|
120
|
+
diagnostics: [
|
|
121
|
+
{
|
|
122
|
+
source: "security",
|
|
123
|
+
path: configPath,
|
|
124
|
+
severity: "error",
|
|
125
|
+
message: error instanceof Error ? error.message : String(error),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
81
129
|
}
|
|
82
130
|
}
|
|
131
|
+
export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
|
|
132
|
+
return loadSecurityConfigWithDiagnostics(appHomeDir).config;
|
|
133
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SecurityConfig } from "./types.js";
|
|
2
|
+
type ValidationStage = "request" | "redirect";
|
|
3
|
+
export interface NetworkGuardContext {
|
|
4
|
+
config: SecurityConfig;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidatedNetworkTarget {
|
|
7
|
+
url: string;
|
|
8
|
+
hostname: string;
|
|
9
|
+
resolvedAddress?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class NetworkGuardError extends Error {
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly stage: ValidationStage;
|
|
14
|
+
readonly category: string;
|
|
15
|
+
readonly resolvedHost?: string;
|
|
16
|
+
readonly resolvedAddress?: string;
|
|
17
|
+
constructor(options: {
|
|
18
|
+
url: string;
|
|
19
|
+
stage: ValidationStage;
|
|
20
|
+
category: string;
|
|
21
|
+
message: string;
|
|
22
|
+
resolvedHost?: string;
|
|
23
|
+
resolvedAddress?: string;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export declare function validateNetworkTarget(url: string, context: NetworkGuardContext): Promise<ValidatedNetworkTarget>;
|
|
27
|
+
export declare function validateRedirectTarget(url: string, context: NetworkGuardContext): Promise<ValidatedNetworkTarget>;
|
|
28
|
+
export {};
|