@oyasmi/pipiclaw 0.5.9 → 0.6.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.
Files changed (51) hide show
  1. package/dist/agent/channel-runner.d.ts +8 -0
  2. package/dist/agent/channel-runner.js +132 -24
  3. package/dist/agent/context-budget.d.ts +9 -0
  4. package/dist/agent/context-budget.js +31 -0
  5. package/dist/agent/session-events.js +11 -4
  6. package/dist/agent/type-guards.js +4 -2
  7. package/dist/agent/types.d.ts +10 -3
  8. package/dist/agent/types.js +1 -0
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +2 -2
  11. package/dist/memory/candidates.d.ts +8 -5
  12. package/dist/memory/candidates.js +92 -42
  13. package/dist/memory/consolidation.js +13 -4
  14. package/dist/memory/recall.d.ts +2 -2
  15. package/dist/memory/recall.js +2 -3
  16. package/dist/memory/session.js +2 -2
  17. package/dist/memory/sidecar-worker.d.ts +1 -0
  18. package/dist/memory/sidecar-worker.js +56 -1
  19. package/dist/paths.d.ts +1 -0
  20. package/dist/paths.js +1 -0
  21. package/dist/runtime/bootstrap.d.ts +1 -0
  22. package/dist/runtime/bootstrap.js +52 -13
  23. package/dist/runtime/delivery.js +101 -12
  24. package/dist/runtime/dingtalk.d.ts +11 -1
  25. package/dist/runtime/dingtalk.js +69 -24
  26. package/dist/runtime/events.d.ts +17 -2
  27. package/dist/runtime/events.js +107 -19
  28. package/dist/security/command-guard.js +4 -0
  29. package/dist/security/config.d.ts +6 -0
  30. package/dist/security/config.js +38 -6
  31. package/dist/security/path-guard.js +4 -0
  32. package/dist/security/platform.d.ts +1 -0
  33. package/dist/security/platform.js +3 -0
  34. package/dist/settings.d.ts +4 -1
  35. package/dist/settings.js +31 -6
  36. package/dist/shared/config-diagnostics.d.ts +7 -0
  37. package/dist/shared/config-diagnostics.js +3 -0
  38. package/dist/subagents/tool.d.ts +2 -0
  39. package/dist/subagents/tool.js +2 -3
  40. package/dist/tools/config.d.ts +7 -0
  41. package/dist/tools/config.js +63 -7
  42. package/dist/tools/index.d.ts +5 -0
  43. package/dist/tools/index.js +3 -2
  44. package/dist/web/client.d.ts +1 -0
  45. package/dist/web/client.js +30 -18
  46. package/dist/web/config.d.ts +1 -0
  47. package/dist/web/config.js +1 -0
  48. package/dist/web/fetch.d.ts +1 -0
  49. package/dist/web/fetch.js +7 -5
  50. package/dist/web/search-providers.js +6 -3
  51. package/package.json +1 -1
@@ -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 = [];
@@ -180,8 +181,10 @@ export class DingTalkBot {
180
181
  this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
181
182
  return this.handleRawMessage(msg);
182
183
  });
183
- log.logConnected();
184
- await this.doReconnect(true); // Initial connection
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
+ }
185
188
  }
186
189
  handleRawMessage(msg) {
187
190
  // 1. Immediate ACK
@@ -213,16 +216,17 @@ export class DingTalkBot {
213
216
  }
214
217
  async doReconnect(immediate = false) {
215
218
  if (this.isReconnecting || this.isStopped || !this.client)
216
- return;
219
+ return false;
217
220
  this.isReconnecting = true;
218
221
  let connectionFailed = false;
222
+ let connected = false;
219
223
  if (!immediate && this.reconnectAttempts > 0) {
220
224
  const delay = Math.min(1000 * 2 ** this.reconnectAttempts + Math.random() * 1000, 30000);
221
225
  log.logInfo(`DingTalk: waiting ${Math.round(delay / 1000)}s before reconnecting...`);
222
226
  await this.waitForDelay(delay);
223
227
  if (this.isStopped || !this.client) {
224
228
  this.isReconnecting = false;
225
- return;
229
+ return false;
226
230
  }
227
231
  }
228
232
  try {
@@ -234,6 +238,11 @@ export class DingTalkBot {
234
238
  this.lastSocketAvailableTime = Date.now();
235
239
  this.reconnectAttempts = 0; // Success, reset backoff
236
240
  log.logInfo("DingTalk: connected to stream.");
241
+ if (!this.hasReportedReady) {
242
+ log.logConnected();
243
+ this.hasReportedReady = true;
244
+ }
245
+ connected = true;
237
246
  // Setup keep alive
238
247
  this.clearKeepAliveTimer();
239
248
  this.keepAliveTimer = this.setTrackedInterval(() => {
@@ -291,6 +300,7 @@ export class DingTalkBot {
291
300
  if (connectionFailed && !this.isStopped) {
292
301
  this.scheduleReconnect(0, false);
293
302
  }
303
+ return connected;
294
304
  }
295
305
  async stop() {
296
306
  log.logInfo("DingTalk: stopping bot");
@@ -352,48 +362,80 @@ export class DingTalkBot {
352
362
  await this.createCard(channelId);
353
363
  }
354
364
  /**
355
- * Stream content to the active AI Card for a channel.
365
+ * Replace the active card content with a full snapshot.
356
366
  */
357
- async streamToCard(channelId, content, finalize = false) {
367
+ async replaceCard(channelId, content, finalize = false, failed = false) {
358
368
  let card = this.activeCards.get(channelId);
359
- if ((!card || card.finished) && !finalize && this.config.cardTemplateId && content.trim()) {
369
+ if ((!card || card.finished) && this.config.cardTemplateId && (content.trim() || !finalize || failed)) {
360
370
  await this.ensureCard(channelId);
361
371
  card = this.activeCards.get(channelId);
362
372
  }
363
373
  if (!card || card.finished) {
364
- if (finalize) {
374
+ if (finalize && content.trim()) {
365
375
  return this.sendPlain(channelId, content);
366
376
  }
367
377
  return false;
368
378
  }
369
- const streamed = await this.streamCard(card, content, finalize);
370
- if (!streamed) {
379
+ const streamed = await this.streamCard(card, content, {
380
+ append: false,
381
+ finalize,
382
+ failed,
383
+ });
384
+ if (!streamed || finalize || failed) {
371
385
  this.activeCards.delete(channelId);
372
386
  }
373
387
  return streamed;
374
388
  }
375
389
  /**
376
- * Finalize the active card for a channel without falling back to a plain message.
377
- * Returns true if a card was finalized, false if no active card existed.
390
+ * Append a delta to the active card transcript.
378
391
  */
379
- async finalizeExistingCard(channelId, content) {
392
+ async appendToCard(channelId, content, finalize = false, failed = false) {
393
+ if (!content && !finalize && !failed) {
394
+ return true;
395
+ }
380
396
  let card = this.activeCards.get(channelId);
381
- if ((!card || card.finished) && this.config.cardTemplateId && content.trim()) {
397
+ if ((!card || card.finished) && !finalize && !failed && this.config.cardTemplateId && content.trim()) {
382
398
  await this.ensureCard(channelId);
383
399
  card = this.activeCards.get(channelId);
384
400
  }
385
401
  if (!card || card.finished) {
402
+ if (finalize && content.trim()) {
403
+ return this.sendPlain(channelId, content);
404
+ }
386
405
  return false;
387
406
  }
388
- const finalized = await this.streamCard(card, content, true);
389
- this.activeCards.delete(channelId);
390
- return finalized;
407
+ const streamed = await this.streamCard(card, content, {
408
+ append: true,
409
+ finalize,
410
+ failed,
411
+ });
412
+ if (!streamed || finalize || failed) {
413
+ this.activeCards.delete(channelId);
414
+ }
415
+ return streamed;
416
+ }
417
+ /**
418
+ * Stream content to the active AI Card for a channel using full replacement semantics.
419
+ */
420
+ async streamToCard(channelId, content, finalize = false) {
421
+ return this.replaceCard(channelId, content, finalize, false);
422
+ }
423
+ /**
424
+ * Finalize the active card for a channel without falling back to a plain message.
425
+ * Returns true if a card was finalized, false if no active card existed.
426
+ */
427
+ async finalizeExistingCard(channelId, content) {
428
+ const finalized = await this.replaceCard(channelId, content, true, false);
429
+ if (!finalized) {
430
+ return false;
431
+ }
432
+ return true;
391
433
  }
392
434
  /**
393
435
  * Finalize and remove the active card for a channel.
394
436
  */
395
437
  async finalizeCard(channelId, content) {
396
- const finalized = await this.finalizeExistingCard(channelId, content);
438
+ const finalized = await this.replaceCard(channelId, content, true, false);
397
439
  if (!finalized) {
398
440
  return this.sendPlain(channelId, content);
399
441
  }
@@ -519,7 +561,7 @@ export class DingTalkBot {
519
561
  this.activeCards.set(channelId, card);
520
562
  return card;
521
563
  }
522
- async streamCard(card, content, finalize = false) {
564
+ async streamCard(card, content, options) {
523
565
  // Refresh token if needed
524
566
  const ageSecs = Date.now() / 1000 - card.createdAt;
525
567
  if (ageSecs > TOKEN_REFRESH_SECS) {
@@ -533,9 +575,12 @@ export class DingTalkBot {
533
575
  guid: `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
534
576
  key: card.templateKey,
535
577
  content,
536
- isFull: true,
537
- isFinalize: finalize,
538
- isError: false,
578
+ append: options.append,
579
+ finished: options.finalize,
580
+ failed: options.failed,
581
+ isFull: !options.append,
582
+ isFinalize: options.finalize,
583
+ isError: options.failed,
539
584
  };
540
585
  const start = Date.now();
541
586
  try {
@@ -550,8 +595,8 @@ export class DingTalkBot {
550
595
  log.logWarning(`DingTalk Card: streaming request took ${duration}ms (slow)`);
551
596
  }
552
597
  card.lastUpdated = Date.now() / 1000;
553
- card.content = content;
554
- if (finalize) {
598
+ card.content = options.append ? `${card.content}${content}` : content;
599
+ if (options.finalize || options.failed) {
555
600
  card.finished = true;
556
601
  }
557
602
  return true;
@@ -1,14 +1,22 @@
1
+ import type { SecurityConfig } from "../security/types.js";
1
2
  import type { DingTalkBot } from "./dingtalk.js";
3
+ export interface EventAction {
4
+ type: "bash";
5
+ command: string;
6
+ timeout?: number;
7
+ }
2
8
  export interface ImmediateEvent {
3
9
  type: "immediate";
4
10
  channelId: string;
5
11
  text: string;
12
+ preAction?: EventAction;
6
13
  }
7
14
  export interface OneShotEvent {
8
15
  type: "one-shot";
9
16
  channelId: string;
10
17
  text: string;
11
18
  at: string;
19
+ preAction?: EventAction;
12
20
  }
13
21
  export interface PeriodicEvent {
14
22
  type: "periodic";
@@ -16,18 +24,20 @@ export interface PeriodicEvent {
16
24
  text: string;
17
25
  schedule: string;
18
26
  timezone: string;
27
+ preAction?: EventAction;
19
28
  }
20
29
  export type ScheduledEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
21
30
  export declare class EventsWatcher {
22
31
  private eventsDir;
23
32
  private bot;
33
+ private commandGuardConfig?;
24
34
  private timers;
25
35
  private crons;
26
36
  private debounceTimers;
27
37
  private startTime;
28
38
  private watcher;
29
39
  private knownFiles;
30
- constructor(eventsDir: string, bot: DingTalkBot);
40
+ constructor(eventsDir: string, bot: DingTalkBot, commandGuardConfig?: SecurityConfig["commandGuard"] | undefined);
31
41
  start(): void;
32
42
  stop(): void;
33
43
  private debounce;
@@ -36,15 +46,20 @@ export declare class EventsWatcher {
36
46
  private handleDelete;
37
47
  private cancelScheduled;
38
48
  private handleFile;
49
+ private parsePreAction;
39
50
  private parseEvent;
40
51
  private handleImmediate;
41
52
  private handleOneShot;
42
53
  private handlePeriodic;
43
54
  private execute;
55
+ private runPreAction;
44
56
  private deleteFile;
57
+ private getInvalidMarkerPath;
58
+ private markInvalid;
59
+ private clearInvalidMarker;
45
60
  private sleep;
46
61
  }
47
62
  /**
48
63
  * Create and start an events watcher.
49
64
  */
50
- export declare function createEventsWatcher(workspaceDir: string, bot: DingTalkBot): EventsWatcher;
65
+ export declare function createEventsWatcher(workspaceDir: string, bot: DingTalkBot, commandGuardConfig?: SecurityConfig["commandGuard"]): EventsWatcher;
@@ -1,8 +1,10 @@
1
+ import { exec } from "child_process";
1
2
  import { Cron } from "croner";
2
- import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
3
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "fs";
3
4
  import { readFile } from "fs/promises";
4
5
  import { join } from "path";
5
6
  import * as log from "../log.js";
7
+ import { guardCommand } from "../security/command-guard.js";
6
8
  // ============================================================================
7
9
  // EventsWatcher
8
10
  // ============================================================================
@@ -11,9 +13,10 @@ const MAX_RETRIES = 3;
11
13
  const RETRY_BASE_MS = 100;
12
14
  const MAX_TIMEOUT_MS = 2_147_483_647;
13
15
  export class EventsWatcher {
14
- constructor(eventsDir, bot) {
16
+ constructor(eventsDir, bot, commandGuardConfig) {
15
17
  this.eventsDir = eventsDir;
16
18
  this.bot = bot;
19
+ this.commandGuardConfig = commandGuardConfig;
17
20
  this.timers = new Map();
18
21
  this.crons = new Map();
19
22
  this.debounceTimers = new Map();
@@ -128,10 +131,11 @@ export class EventsWatcher {
128
131
  }
129
132
  if (!event) {
130
133
  log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
131
- this.deleteFile(filename);
134
+ this.markInvalid(filename, lastError?.message ?? "Unknown event parse error");
132
135
  return;
133
136
  }
134
137
  this.knownFiles.add(filename);
138
+ this.clearInvalidMarker(filename);
135
139
  switch (event.type) {
136
140
  case "immediate":
137
141
  this.handleImmediate(filename, event);
@@ -144,19 +148,39 @@ export class EventsWatcher {
144
148
  break;
145
149
  }
146
150
  }
151
+ parsePreAction(data, filename) {
152
+ if (!data.preAction)
153
+ return undefined;
154
+ if (typeof data.preAction !== "object" || data.preAction === null) {
155
+ throw new Error(`Invalid 'preAction' field in ${filename}, expected an object`);
156
+ }
157
+ const action = data.preAction;
158
+ if (action.type !== "bash") {
159
+ throw new Error(`Unsupported preAction type '${String(action.type)}' in ${filename}, only 'bash' is supported`);
160
+ }
161
+ if (typeof action.command !== "string" || action.command.trim().length === 0) {
162
+ throw new Error(`Missing or empty 'preAction.command' in ${filename}`);
163
+ }
164
+ return {
165
+ type: "bash",
166
+ command: action.command,
167
+ ...(typeof action.timeout === "number" ? { timeout: action.timeout } : {}),
168
+ };
169
+ }
147
170
  parseEvent(content, filename) {
148
171
  const data = JSON.parse(content);
149
172
  if (!data.type || !data.channelId || !data.text) {
150
173
  throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
151
174
  }
175
+ const preAction = this.parsePreAction(data, filename);
152
176
  switch (data.type) {
153
177
  case "immediate":
154
- return { type: "immediate", channelId: data.channelId, text: data.text };
178
+ return { type: "immediate", channelId: data.channelId, text: data.text, preAction };
155
179
  case "one-shot":
156
180
  if (!data.at) {
157
181
  throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
158
182
  }
159
- return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at };
183
+ return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at, preAction };
160
184
  case "periodic":
161
185
  if (!data.schedule) {
162
186
  throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
@@ -170,12 +194,13 @@ export class EventsWatcher {
170
194
  text: data.text,
171
195
  schedule: data.schedule,
172
196
  timezone: data.timezone,
197
+ preAction,
173
198
  };
174
199
  default:
175
200
  throw new Error(`Unknown event type '${data.type}' in ${filename}`);
176
201
  }
177
202
  }
178
- handleImmediate(filename, event) {
203
+ async handleImmediate(filename, event) {
179
204
  const filePath = join(this.eventsDir, filename);
180
205
  try {
181
206
  const stat = statSync(filePath);
@@ -189,14 +214,14 @@ export class EventsWatcher {
189
214
  return;
190
215
  }
191
216
  log.logInfo(`Executing immediate event: ${filename}`);
192
- this.execute(filename, event);
217
+ await this.execute(filename, event);
193
218
  }
194
219
  handleOneShot(filename, event) {
195
220
  const atTime = new Date(event.at).getTime();
196
221
  const now = Date.now();
197
222
  if (!Number.isFinite(atTime)) {
198
223
  log.logWarning(`Invalid one-shot time for ${filename}: ${event.at}`);
199
- this.deleteFile(filename);
224
+ this.markInvalid(filename, `Invalid one-shot time: ${event.at}`);
200
225
  return;
201
226
  }
202
227
  if (atTime <= now) {
@@ -207,22 +232,32 @@ export class EventsWatcher {
207
232
  const delay = atTime - now;
208
233
  if (delay > MAX_TIMEOUT_MS) {
209
234
  log.logWarning(`One-shot event exceeds maximum supported delay for ${filename}: ${event.at}. Use a periodic cron event instead.`);
210
- this.deleteFile(filename);
235
+ this.markInvalid(filename, `One-shot event exceeds maximum supported delay: ${event.at}`);
211
236
  return;
212
237
  }
213
238
  log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
214
- const timer = setTimeout(() => {
239
+ const timer = setTimeout(async () => {
215
240
  this.timers.delete(filename);
216
- log.logInfo(`Executing one-shot event: ${filename}`);
217
- this.execute(filename, event);
241
+ try {
242
+ log.logInfo(`Executing one-shot event: ${filename}`);
243
+ await this.execute(filename, event);
244
+ }
245
+ catch (err) {
246
+ log.logWarning(`One-shot event execution failed: ${filename}`, String(err));
247
+ }
218
248
  }, delay);
219
249
  this.timers.set(filename, timer);
220
250
  }
221
251
  handlePeriodic(filename, event) {
222
252
  try {
223
- const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
224
- log.logInfo(`Executing periodic event: ${filename}`);
225
- this.execute(filename, event, false);
253
+ const cron = new Cron(event.schedule, { timezone: event.timezone }, async () => {
254
+ try {
255
+ log.logInfo(`Executing periodic event: ${filename}`);
256
+ await this.execute(filename, event, false);
257
+ }
258
+ catch (err) {
259
+ log.logWarning(`Periodic event execution failed: ${filename}`, String(err));
260
+ }
226
261
  });
227
262
  this.crons.set(filename, cron);
228
263
  const next = cron.nextRun();
@@ -230,10 +265,20 @@ export class EventsWatcher {
230
265
  }
231
266
  catch (err) {
232
267
  log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
233
- this.deleteFile(filename);
268
+ this.markInvalid(filename, `Invalid cron schedule: ${event.schedule}\n${String(err)}`);
234
269
  }
235
270
  }
236
- execute(filename, event, deleteAfter = true) {
271
+ async execute(filename, event, deleteAfter = true) {
272
+ if (event.preAction) {
273
+ try {
274
+ await this.runPreAction(event.preAction, filename);
275
+ }
276
+ catch (err) {
277
+ const reason = err instanceof Error ? err.message : String(err);
278
+ log.logInfo(`Pre-action gate blocked event: ${filename} (${reason})`);
279
+ return;
280
+ }
281
+ }
237
282
  let scheduleInfo;
238
283
  switch (event.type) {
239
284
  case "immediate":
@@ -269,6 +314,25 @@ export class EventsWatcher {
269
314
  }
270
315
  }
271
316
  }
317
+ runPreAction(action, filename) {
318
+ if (this.commandGuardConfig?.enabled) {
319
+ const guardResult = guardCommand(action.command, this.commandGuardConfig);
320
+ if (!guardResult.allowed) {
321
+ log.logWarning(`Pre-action command blocked by guard for ${filename}: ${guardResult.reason}`);
322
+ return Promise.reject(new Error(`guard: ${guardResult.reason}`));
323
+ }
324
+ }
325
+ return new Promise((resolve, reject) => {
326
+ const child = exec(action.command, { timeout: action.timeout ?? 10_000 });
327
+ child.on("close", (code) => {
328
+ if (code === 0)
329
+ resolve();
330
+ else
331
+ reject(new Error(`exit ${code}`));
332
+ });
333
+ child.on("error", reject);
334
+ });
335
+ }
272
336
  deleteFile(filename) {
273
337
  const filePath = join(this.eventsDir, filename);
274
338
  try {
@@ -279,8 +343,32 @@ export class EventsWatcher {
279
343
  log.logWarning(`Failed to delete event file: ${filename}`, String(err));
280
344
  }
281
345
  }
346
+ this.clearInvalidMarker(filename);
282
347
  this.knownFiles.delete(filename);
283
348
  }
349
+ getInvalidMarkerPath(filename) {
350
+ return join(this.eventsDir, `${filename}.error.txt`);
351
+ }
352
+ markInvalid(filename, message) {
353
+ try {
354
+ writeFileSync(this.getInvalidMarkerPath(filename), [`timestamp: ${new Date().toISOString()}`, `file: ${filename}`, "", message.trim()].join("\n"), "utf-8");
355
+ }
356
+ catch (err) {
357
+ log.logWarning(`Failed to write event error marker: ${filename}`, String(err));
358
+ }
359
+ this.knownFiles.add(filename);
360
+ }
361
+ clearInvalidMarker(filename) {
362
+ const markerPath = this.getInvalidMarkerPath(filename);
363
+ try {
364
+ unlinkSync(markerPath);
365
+ }
366
+ catch (err) {
367
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
368
+ log.logWarning(`Failed to delete event error marker: ${filename}`, String(err));
369
+ }
370
+ }
371
+ }
284
372
  sleep(ms) {
285
373
  return new Promise((resolve) => setTimeout(resolve, ms));
286
374
  }
@@ -288,7 +376,7 @@ export class EventsWatcher {
288
376
  /**
289
377
  * Create and start an events watcher.
290
378
  */
291
- export function createEventsWatcher(workspaceDir, bot) {
379
+ export function createEventsWatcher(workspaceDir, bot, commandGuardConfig) {
292
380
  const eventsDir = join(workspaceDir, "events");
293
- return new EventsWatcher(eventsDir, bot);
381
+ return new EventsWatcher(eventsDir, bot, commandGuardConfig);
294
382
  }
@@ -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;
@@ -34,14 +34,30 @@ function asStringArray(value) {
34
34
  function asOptionalString(value) {
35
35
  return typeof value === "string" && value.trim() ? value : undefined;
36
36
  }
37
- function mergeSecurityConfig(source) {
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) {
38
46
  if (!isRecord(source)) {
47
+ pushInvalidSecurityDiagnostic(diagnostics, configPath, "root", "expected a JSON object; using defaults");
39
48
  return DEFAULT_SECURITY_CONFIG;
40
49
  }
41
50
  const commandGuard = isRecord(source.commandGuard) ? source.commandGuard : {};
42
51
  const pathGuard = isRecord(source.pathGuard) ? source.pathGuard : {};
43
52
  const networkGuard = isRecord(source.networkGuard) ? source.networkGuard : {};
44
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
+ }
45
61
  return {
46
62
  enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_SECURITY_CONFIG.enabled,
47
63
  commandGuard: {
@@ -85,17 +101,33 @@ function mergeSecurityConfig(source) {
85
101
  export function getSecurityConfigPath(appHomeDir = APP_HOME_DIR) {
86
102
  return join(appHomeDir, "security.json");
87
103
  }
88
- export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
104
+ export function loadSecurityConfigWithDiagnostics(appHomeDir = APP_HOME_DIR) {
89
105
  const configPath = getSecurityConfigPath(appHomeDir);
90
106
  if (!existsSync(configPath)) {
91
- return DEFAULT_SECURITY_CONFIG;
107
+ return { config: DEFAULT_SECURITY_CONFIG, diagnostics: [] };
92
108
  }
93
109
  try {
94
110
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
95
- return mergeSecurityConfig(raw);
111
+ const diagnostics = [];
112
+ return {
113
+ config: mergeSecurityConfig(raw, configPath, diagnostics),
114
+ diagnostics,
115
+ };
96
116
  }
97
117
  catch (error) {
98
- console.warn(`Failed to load security config from ${configPath}: ${error}`);
99
- return DEFAULT_SECURITY_CONFIG;
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
+ };
100
129
  }
101
130
  }
131
+ export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
132
+ return loadSecurityConfigWithDiagnostics(appHomeDir).config;
133
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, realpathSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
4
+ import { isWindowsPlatform } from "./platform.js";
4
5
  const PRIVATE_KEY_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx"]);
5
6
  const PRIVATE_KEY_NAME_HINTS = /(id_rsa|id_ed25519|private|secret|credentials)/i;
6
7
  const PROC_MEM_PATH = /^\/proc\/\d+\/mem(?:\/|$)/;
@@ -197,6 +198,9 @@ export function guardPath(rawPath, operation, ctx) {
197
198
  if (!ctx.config.enabled) {
198
199
  return { allowed: true, operation, rawPath };
199
200
  }
201
+ if (isWindowsPlatform()) {
202
+ return { allowed: true, operation, rawPath };
203
+ }
200
204
  const homeDir = ctx.homeDir ?? homedir();
201
205
  const effectiveCtx = {
202
206
  ...ctx,
@@ -0,0 +1 @@
1
+ export declare function isWindowsPlatform(): boolean;
@@ -0,0 +1,3 @@
1
+ export function isWindowsPlatform() {
2
+ return process.platform === "win32";
3
+ }
@@ -7,6 +7,7 @@
7
7
  * This module currently provides only PipiclawSettingsManager.
8
8
  */
9
9
  import type { Transport } from "@mariozechner/pi-ai";
10
+ import type { ConfigDiagnostic } from "./shared/config-diagnostics.js";
10
11
  type PackageSource = string | {
11
12
  source: string;
12
13
  extensions?: string[];
@@ -83,10 +84,13 @@ export interface PipiclawSettings {
83
84
  export declare class PipiclawSettingsManager {
84
85
  private settingsPath;
85
86
  private settings;
87
+ private loadErrors;
86
88
  constructor(baseDir: string);
87
89
  private load;
88
90
  private save;
89
91
  reload(): void;
92
+ drainErrors(): SettingsError[];
93
+ getDiagnostics(): ConfigDiagnostic[];
90
94
  getCompactionSettings(): PipiclawCompactionSettings;
91
95
  getCompactionEnabled(): boolean;
92
96
  setCompactionEnabled(enabled: boolean): void;
@@ -176,6 +180,5 @@ export declare class PipiclawSettingsManager {
176
180
  getProjectSettings(): Settings;
177
181
  applyOverrides(overrides: Partial<Settings>): void;
178
182
  flush(): Promise<void>;
179
- drainErrors(): SettingsError[];
180
183
  }
181
184
  export {};