@hasna/knowledge 0.2.6 → 0.2.7

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/src/cli.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  */
7
7
  import { defaultStorePath, loadStore, saveStore, withLock, makeId, makeShortId, ensureStore, type KnowledgeItem } from './store';
8
8
  import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace';
9
- import { getKnowledgeDbStats, migrateKnowledgeDb } from './knowledge-db';
9
+ import { getKnowledgeDbStats, migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
10
10
  import { createArtifactStore } from './artifact-store';
11
11
  import { initializeWikiLayout } from './wiki-layout';
12
12
  import { ingestOpenFilesManifest } from './manifest-ingest';
13
13
  import { consumeOpenFilesOutbox } from './outbox-consume';
14
+ import { approvalStatus, assertS3ReadAllowed, assertWebSearchAllowed, createApprovalGate, recordAuditEvent, recordRedactionFindings, redactSecrets, resolveSafetyPolicy } from './safety';
14
15
  import pkg from '../package.json' with { type: 'json' };
15
16
 
16
17
  type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@@ -61,7 +62,7 @@ interface ParseResult {
61
62
  flags: Flags;
62
63
  }
63
64
 
64
- const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'ingest', 'reindex', 'help'];
65
+ const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'ingest', 'reindex', 'safety', 'help'];
65
66
  const COMMAND_ALIASES: Record<string, string> = {
66
67
  ls: 'list',
67
68
  rm: 'delete',
@@ -166,6 +167,7 @@ Commands:
166
167
  wiki init Initialize scalable wiki/schema/index/log artifacts
167
168
  ingest manifest <file|s3://> Ingest an open-files manifest into knowledge.db
168
169
  reindex outbox <file|s3://> Consume open-files change events and invalidate chunks
170
+ safety status|check|approve|audit|redact
169
171
  help [command] Show help
170
172
 
171
173
  Global Options:
@@ -229,6 +231,7 @@ function printCommandHelp(command: string): void {
229
231
  if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
230
232
  if (command === 'ingest') { console.log('Usage: open-knowledge ingest manifest <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
231
233
  if (command === 'reindex') { console.log('Usage: open-knowledge reindex outbox <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
234
+ if (command === 'safety') { console.log('Usage: open-knowledge safety status|check|approve|audit|redact [args] [--scope local|global|project] [--json]'); return; }
232
235
  printGlobalHelp();
233
236
  }
234
237
 
@@ -273,11 +276,11 @@ async function run(argv: string[]): Promise<void> {
273
276
  if (flags.completions) {
274
277
  const shell = flags.completions;
275
278
  if (shell === 'bash') {
276
- console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki ingest reindex help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
279
+ console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki ingest reindex safety help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
277
280
  } else if (shell === 'zsh') {
278
- console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki ingest reindex help ls rm edit unarchive)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
281
+ console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki ingest reindex safety help ls rm edit unarchive)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
279
282
  } else if (shell === 'fish') {
280
- console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki ingest reindex help ls rm edit unarchive"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -l archived; complete -c open-knowledge -l include-archived; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
283
+ console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki ingest reindex safety help ls rm edit unarchive"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -l archived; complete -c open-knowledge -l include-archived; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
281
284
  } else {
282
285
  throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
283
286
  }
@@ -347,6 +350,132 @@ async function run(argv: string[]): Promise<void> {
347
350
  return;
348
351
  }
349
352
 
353
+ if (command === 'safety') {
354
+ const action = positional[1] ?? 'status';
355
+ const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
356
+ const config = readKnowledgeConfig(resolvedWorkspace.configPath);
357
+ const policy = resolveSafetyPolicy(config, resolvedWorkspace);
358
+ migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
359
+ const db = openKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
360
+ try {
361
+ if (action === 'status') {
362
+ output({
363
+ ok: true,
364
+ mode: policy.mode,
365
+ workspace: resolvedWorkspace.home,
366
+ allow_write_roots: policy.allowWriteRoots,
367
+ read_only_source_access: policy.readOnlySourceAccess,
368
+ network: policy.network,
369
+ redaction: policy.redaction,
370
+ approvals: policy.approvals,
371
+ message: `Safety policy: ${policy.mode}`,
372
+ }, flags.json);
373
+ return;
374
+ }
375
+ if (action === 'check') {
376
+ const checkAction = positional[2] ?? 'generated_write';
377
+ const target = positional[3] ?? null;
378
+ let decision: ReturnType<typeof approvalStatus> | { action: string; target_uri: string | null; approval_required: false; approved: boolean; decision: string };
379
+ try {
380
+ if (checkAction === 'web_search') {
381
+ assertWebSearchAllowed(policy);
382
+ decision = { action: checkAction, target_uri: target, approval_required: false, approved: true, decision: 'allow' };
383
+ } else if (checkAction === 's3_read') {
384
+ if (!target) throw new Error('safety check s3_read requires an s3:// target.');
385
+ assertS3ReadAllowed(target, policy);
386
+ decision = { action: checkAction, target_uri: target, approval_required: false, approved: true, decision: 'allow' };
387
+ } else {
388
+ decision = approvalStatus(db, policy, checkAction, target);
389
+ }
390
+ recordAuditEvent(db, {
391
+ event_type: 'safety_check',
392
+ action: checkAction,
393
+ target_uri: target,
394
+ decision: decision.decision === 'allow' ? 'allow' : 'requires_approval',
395
+ metadata: decision,
396
+ });
397
+ output({ ok: true, ...decision, message: `Safety check ${decision.decision}` }, flags.json);
398
+ return;
399
+ } catch (error) {
400
+ recordAuditEvent(db, {
401
+ event_type: 'safety_check',
402
+ action: checkAction,
403
+ target_uri: target,
404
+ decision: 'deny',
405
+ metadata: { error: error instanceof Error ? error.message : String(error) },
406
+ });
407
+ throw error;
408
+ }
409
+ }
410
+ if (action === 'approve') {
411
+ const approveAction = positional[2] ?? 'generated_write';
412
+ const target = positional[3] ?? null;
413
+ const approval = createApprovalGate(db, {
414
+ action: approveAction,
415
+ target_uri: target,
416
+ reason: 'local-cli approval',
417
+ metadata: { scope: flags.scope ?? 'global' },
418
+ });
419
+ recordAuditEvent(db, {
420
+ event_type: 'approval',
421
+ action: approveAction,
422
+ target_uri: target,
423
+ decision: 'allow',
424
+ metadata: { approval_id: approval.id },
425
+ });
426
+ output({ ok: true, ...approval, action: approveAction, target_uri: target, message: `Approved ${approveAction}` }, flags.json);
427
+ return;
428
+ }
429
+ if (action === 'audit') {
430
+ const rows = db.query<{
431
+ id: string;
432
+ event_type: string;
433
+ action: string;
434
+ target_uri: string | null;
435
+ decision: string;
436
+ metadata_json: string;
437
+ created_at: string;
438
+ }, []>(
439
+ 'SELECT id, event_type, action, target_uri, decision, metadata_json, created_at FROM audit_events ORDER BY created_at DESC LIMIT 50',
440
+ ).all().map((row) => ({
441
+ id: row.id,
442
+ event_type: row.event_type,
443
+ action: row.action,
444
+ target_uri: row.target_uri,
445
+ decision: row.decision,
446
+ metadata: JSON.parse(row.metadata_json),
447
+ created_at: row.created_at,
448
+ }));
449
+ output({ ok: true, events: rows, message: `${rows.length} audit event(s)` }, flags.json);
450
+ return;
451
+ }
452
+ if (action === 'redact') {
453
+ const text = positional.slice(2).join(' ');
454
+ if (!text) throw new Error('Usage: open-knowledge safety redact <text>');
455
+ const result = redactSecrets(text, policy);
456
+ if (result.findings.length > 0) {
457
+ recordRedactionFindings(db, {
458
+ source_uri: 'safety://redact',
459
+ findings: result.findings,
460
+ metadata: { command: 'safety redact' },
461
+ });
462
+ }
463
+ recordAuditEvent(db, {
464
+ event_type: 'redaction',
465
+ action: 'safety_redact',
466
+ target_uri: 'safety://redact',
467
+ decision: result.findings.length > 0 ? 'redacted' : 'allow',
468
+ metadata: { findings: result.findings.length },
469
+ });
470
+ output({ ok: true, text: result.text, findings: result.findings, message: `Redacted ${result.findings.length} finding(s)` }, flags.json);
471
+ return;
472
+ }
473
+ throw new Error("Invalid safety action. Use 'status', 'check', 'approve', 'audit', or 'redact'.");
474
+ } finally {
475
+ db.close();
476
+ }
477
+ }
478
+
350
479
  if (command === 'ingest') {
351
480
  const action = positional[1] ?? '';
352
481
  if (action !== 'manifest') throw new Error("Invalid ingest action. Use 'manifest'.");
@@ -354,10 +483,12 @@ async function run(argv: string[]): Promise<void> {
354
483
  if (!input) throw new Error('Usage: open-knowledge ingest manifest <file|s3://bucket/key>');
355
484
  const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
356
485
  const config = readKnowledgeConfig(resolvedWorkspace.configPath);
486
+ const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
357
487
  const result = await ingestOpenFilesManifest({
358
488
  dbPath: resolvedWorkspace.knowledgeDbPath,
359
489
  input,
360
490
  config,
491
+ safetyPolicy,
361
492
  });
362
493
  output({ ok: true, ...result, message: `Ingested ${result.items_seen} manifest item(s)` }, flags.json);
363
494
  return;
@@ -370,10 +501,12 @@ async function run(argv: string[]): Promise<void> {
370
501
  if (!input) throw new Error('Usage: open-knowledge reindex outbox <file|s3://bucket/key>');
371
502
  const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
372
503
  const config = readKnowledgeConfig(resolvedWorkspace.configPath);
504
+ const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
373
505
  const result = await consumeOpenFilesOutbox({
374
506
  dbPath: resolvedWorkspace.knowledgeDbPath,
375
507
  input,
376
508
  config,
509
+ safetyPolicy,
377
510
  });
378
511
  output({ ok: true, ...result, message: `Consumed ${result.events_seen} outbox event(s)` }, flags.json);
379
512
  return;
@@ -1,7 +1,7 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { ensureParentDir } from './workspace';
3
3
 
4
- export const CURRENT_SCHEMA_VERSION = 2;
4
+ export const CURRENT_SCHEMA_VERSION = 3;
5
5
 
6
6
  export interface KnowledgeDbStats {
7
7
  schema_version: number;
@@ -13,6 +13,9 @@ export interface KnowledgeDbStats {
13
13
  indexes: number;
14
14
  runs: number;
15
15
  run_events: number;
16
+ redaction_findings: number;
17
+ audit_events: number;
18
+ approval_gates: number;
16
19
  }
17
20
 
18
21
  const MIGRATION_1 = `
@@ -199,6 +202,39 @@ INSERT OR IGNORE INTO schema_versions(version, applied_at)
199
202
  VALUES (2, datetime('now'));
200
203
  `;
201
204
 
205
+ const MIGRATION_3 = `
206
+ CREATE TABLE IF NOT EXISTS audit_events (
207
+ id TEXT PRIMARY KEY,
208
+ event_type TEXT NOT NULL,
209
+ action TEXT NOT NULL,
210
+ target_uri TEXT,
211
+ decision TEXT NOT NULL,
212
+ metadata_json TEXT NOT NULL DEFAULT '{}',
213
+ created_at TEXT NOT NULL
214
+ );
215
+
216
+ CREATE TABLE IF NOT EXISTS approval_gates (
217
+ id TEXT PRIMARY KEY,
218
+ action TEXT NOT NULL,
219
+ target_uri TEXT,
220
+ status TEXT NOT NULL,
221
+ reason TEXT,
222
+ approved_by TEXT,
223
+ metadata_json TEXT NOT NULL DEFAULT '{}',
224
+ created_at TEXT NOT NULL,
225
+ updated_at TEXT NOT NULL
226
+ );
227
+
228
+ CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action);
229
+ CREATE INDEX IF NOT EXISTS idx_audit_events_target ON audit_events(target_uri);
230
+ CREATE INDEX IF NOT EXISTS idx_audit_events_created ON audit_events(created_at);
231
+ CREATE INDEX IF NOT EXISTS idx_approval_gates_action ON approval_gates(action);
232
+ CREATE INDEX IF NOT EXISTS idx_approval_gates_status ON approval_gates(status);
233
+
234
+ INSERT OR IGNORE INTO schema_versions(version, applied_at)
235
+ VALUES (3, datetime('now'));
236
+ `;
237
+
202
238
  export function openKnowledgeDb(path: string): Database {
203
239
  ensureParentDir(path);
204
240
  const db = new Database(path);
@@ -211,6 +247,7 @@ export function migrateKnowledgeDb(path: string): { path: string; schema_version
211
247
  try {
212
248
  db.exec(MIGRATION_1);
213
249
  if (getSchemaVersion(db) < 2) db.exec(MIGRATION_2);
250
+ if (getSchemaVersion(db) < 3) db.exec(MIGRATION_3);
214
251
  return { path, schema_version: getSchemaVersion(db) };
215
252
  } finally {
216
253
  db.close();
@@ -240,6 +277,9 @@ export function getKnowledgeDbStats(path: string): KnowledgeDbStats {
240
277
  indexes: count(db, 'knowledge_indexes'),
241
278
  runs: count(db, 'runs'),
242
279
  run_events: count(db, 'run_events'),
280
+ redaction_findings: count(db, 'redaction_findings'),
281
+ audit_events: count(db, 'audit_events'),
282
+ approval_gates: count(db, 'approval_gates'),
243
283
  };
244
284
  } finally {
245
285
  db.close();
@@ -5,11 +5,20 @@ import type { Database } from 'bun:sqlite';
5
5
  import { migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
6
6
  import { parseSourceRef, type SourceRef } from './source-ref';
7
7
  import type { KnowledgeConfig } from './workspace';
8
+ import {
9
+ assertS3ReadAllowed,
10
+ assertWriteAllowed,
11
+ recordAuditEvent,
12
+ recordRedactionFindings,
13
+ redactSecrets,
14
+ type SafetyPolicy,
15
+ } from './safety';
8
16
 
9
17
  export interface ManifestIngestOptions {
10
18
  dbPath: string;
11
19
  input: string;
12
20
  config?: KnowledgeConfig;
21
+ safetyPolicy?: SafetyPolicy;
13
22
  now?: Date;
14
23
  maxChunkChars?: number;
15
24
  chunkOverlapChars?: number;
@@ -23,6 +32,7 @@ export interface ManifestIngestResult {
23
32
  revisions_upserted: number;
24
33
  chunks_inserted: number;
25
34
  chunks_deleted: number;
35
+ redactions: number;
26
36
  skipped: number;
27
37
  }
28
38
 
@@ -209,11 +219,12 @@ function parseManifestText(text: string): ManifestObject[] {
209
219
  });
210
220
  }
211
221
 
212
- async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string> {
222
+ async function readS3Text(uri: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
213
223
  const parsed = new URL(uri);
214
224
  const bucket = parsed.hostname;
215
225
  const key = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
216
226
  if (!bucket || !key) throw new Error(`Invalid S3 manifest URI: ${uri}`);
227
+ if (safetyPolicy) assertS3ReadAllowed(uri, safetyPolicy);
217
228
  const [{ S3Client, GetObjectCommand }, { fromIni }] = await Promise.all([
218
229
  import('@aws-sdk/client-s3'),
219
230
  import('@aws-sdk/credential-providers'),
@@ -229,8 +240,8 @@ async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string
229
240
  return await response.Body.transformToString();
230
241
  }
231
242
 
232
- async function readManifestInput(input: string, config?: KnowledgeConfig): Promise<string> {
233
- if (input.startsWith('s3://')) return readS3Text(input, config);
243
+ async function readManifestInput(input: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
244
+ if (input.startsWith('s3://')) return readS3Text(input, config, safetyPolicy);
234
245
  if (!existsSync(input)) throw new Error(`Manifest not found: ${input}`);
235
246
  return readFileSync(input, 'utf8');
236
247
  }
@@ -338,9 +349,26 @@ function upsertRevision(db: Database, sourceId: string, item: NormalizedManifest
338
349
  return row.id;
339
350
  }
340
351
 
341
- function insertChunks(db: Database, sourceRevisionId: string, item: NormalizedManifestItem, now: string, maxChars: number, overlapChars: number): number {
342
- if (!item.text || item.status.toLowerCase() === 'deleted') return 0;
343
- const chunks = chunkText(item.text, maxChars, overlapChars);
352
+ function insertChunks(db: Database, sourceRevisionId: string, item: NormalizedManifestItem, now: string, maxChars: number, overlapChars: number, safetyPolicy?: SafetyPolicy): { chunksInserted: number; redactions: number } {
353
+ if (!item.text || item.status.toLowerCase() === 'deleted') return { chunksInserted: 0, redactions: 0 };
354
+ const redacted = redactSecrets(item.text, safetyPolicy);
355
+ if (redacted.findings.length > 0) {
356
+ recordRedactionFindings(db, {
357
+ source_uri: item.sourceUri,
358
+ findings: redacted.findings,
359
+ metadata: { source_ref: item.sourceRef, revision: item.revision },
360
+ created_at: now,
361
+ });
362
+ recordAuditEvent(db, {
363
+ event_type: 'redaction',
364
+ action: 'source_text_redact',
365
+ target_uri: item.sourceUri,
366
+ decision: 'redacted',
367
+ metadata: { findings: redacted.findings.length, source_ref: item.sourceRef, revision: item.revision },
368
+ created_at: now,
369
+ });
370
+ }
371
+ const chunks = chunkText(redacted.text, maxChars, overlapChars);
344
372
  for (const chunk of chunks) {
345
373
  const chunkId = stableId('chk', `${sourceRevisionId}\u0000${chunk.ordinal}\u0000${chunk.text}`);
346
374
  const metadata = {
@@ -373,7 +401,7 @@ function insertChunks(db: Database, sourceRevisionId: string, item: NormalizedMa
373
401
  [chunkId, chunk.text, item.title ?? '', item.sourceUri],
374
402
  );
375
403
  }
376
- return chunks.length;
404
+ return { chunksInserted: chunks.length, redactions: redacted.findings.length };
377
405
  }
378
406
 
379
407
  export async function ingestOpenFilesManifest(options: ManifestIngestOptions): Promise<ManifestIngestResult> {
@@ -383,8 +411,9 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
383
411
  if (maxChunkChars < 500) throw new Error('maxChunkChars must be at least 500.');
384
412
  if (chunkOverlapChars < 0 || chunkOverlapChars >= maxChunkChars) throw new Error('chunkOverlapChars must be less than maxChunkChars.');
385
413
 
414
+ if (options.safetyPolicy) assertWriteAllowed(options.dbPath, options.safetyPolicy);
386
415
  migrateKnowledgeDb(options.dbPath);
387
- const text = await readManifestInput(options.input, options.config);
416
+ const text = await readManifestInput(options.input, options.config, options.safetyPolicy);
388
417
  const items = parseManifestText(text);
389
418
  const db = openKnowledgeDb(options.dbPath);
390
419
  try {
@@ -393,7 +422,16 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
393
422
  const seenRevisions = new Set<string>();
394
423
  let chunksInserted = 0;
395
424
  let chunksDeleted = 0;
425
+ let redactions = 0;
396
426
  let skipped = 0;
427
+ recordAuditEvent(db, {
428
+ event_type: 'source_read',
429
+ action: options.input.startsWith('s3://') ? 's3_manifest_read' : 'local_manifest_read',
430
+ target_uri: options.input,
431
+ decision: 'allow',
432
+ metadata: { items: items.length, read_only: true },
433
+ created_at: now,
434
+ });
397
435
  for (const raw of items) {
398
436
  const item = normalizeManifestItem(raw, now);
399
437
  const sourceId = upsertSource(db, item, now);
@@ -403,8 +441,18 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
403
441
  if (item.text || item.status.toLowerCase() === 'deleted') {
404
442
  chunksDeleted += deleteChunksForRevision(db, revisionId);
405
443
  }
406
- chunksInserted += insertChunks(db, revisionId, item, now, maxChunkChars, chunkOverlapChars);
444
+ const inserted = insertChunks(db, revisionId, item, now, maxChunkChars, chunkOverlapChars, options.safetyPolicy);
445
+ chunksInserted += inserted.chunksInserted;
446
+ redactions += inserted.redactions;
407
447
  }
448
+ recordAuditEvent(db, {
449
+ event_type: 'write',
450
+ action: 'knowledge_manifest_ingest',
451
+ target_uri: options.dbPath,
452
+ decision: 'allow',
453
+ metadata: { items: items.length, sources: seenSources.size, revisions: seenRevisions.size, chunks_inserted: chunksInserted, redactions },
454
+ created_at: now,
455
+ });
408
456
  return {
409
457
  path: options.input,
410
458
  db_path: options.dbPath,
@@ -413,6 +461,7 @@ export async function ingestOpenFilesManifest(options: ManifestIngestOptions): P
413
461
  revisions_upserted: seenRevisions.size,
414
462
  chunks_inserted: chunksInserted,
415
463
  chunks_deleted: chunksDeleted,
464
+ redactions,
416
465
  skipped,
417
466
  };
418
467
  })();
@@ -5,6 +5,7 @@ import type { Database } from 'bun:sqlite';
5
5
  import { migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db';
6
6
  import { parseSourceRef, type SourceRef } from './source-ref';
7
7
  import type { KnowledgeConfig } from './workspace';
8
+ import { assertS3ReadAllowed, assertWriteAllowed, recordAuditEvent, type SafetyPolicy } from './safety';
8
9
 
9
10
  type OutboxObject = Record<string, unknown>;
10
11
 
@@ -12,6 +13,7 @@ export interface OutboxConsumeOptions {
12
13
  dbPath: string;
13
14
  input: string;
14
15
  config?: KnowledgeConfig;
16
+ safetyPolicy?: SafetyPolicy;
15
17
  now?: Date;
16
18
  }
17
19
 
@@ -165,11 +167,12 @@ function parseOutboxText(text: string): OutboxObject[] {
165
167
  });
166
168
  }
167
169
 
168
- async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string> {
170
+ async function readS3Text(uri: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
169
171
  const parsed = new URL(uri);
170
172
  const bucket = parsed.hostname;
171
173
  const key = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
172
174
  if (!bucket || !key) throw new Error(`Invalid S3 outbox URI: ${uri}`);
175
+ if (safetyPolicy) assertS3ReadAllowed(uri, safetyPolicy);
173
176
  const [{ S3Client, GetObjectCommand }, { fromIni }] = await Promise.all([
174
177
  import('@aws-sdk/client-s3'),
175
178
  import('@aws-sdk/credential-providers'),
@@ -185,8 +188,8 @@ async function readS3Text(uri: string, config?: KnowledgeConfig): Promise<string
185
188
  return await response.Body.transformToString();
186
189
  }
187
190
 
188
- async function readOutboxInput(input: string, config?: KnowledgeConfig): Promise<string> {
189
- if (input.startsWith('s3://')) return readS3Text(input, config);
191
+ async function readOutboxInput(input: string, config?: KnowledgeConfig, safetyPolicy?: SafetyPolicy): Promise<string> {
192
+ if (input.startsWith('s3://')) return readS3Text(input, config, safetyPolicy);
190
193
  if (!existsSync(input)) throw new Error(`Outbox not found: ${input}`);
191
194
  return readFileSync(input, 'utf8');
192
195
  }
@@ -318,8 +321,9 @@ function isPermissionEvent(eventType: string): boolean {
318
321
 
319
322
  export async function consumeOpenFilesOutbox(options: OutboxConsumeOptions): Promise<OutboxConsumeResult> {
320
323
  const now = (options.now ?? new Date()).toISOString();
324
+ if (options.safetyPolicy) assertWriteAllowed(options.dbPath, options.safetyPolicy);
321
325
  migrateKnowledgeDb(options.dbPath);
322
- const text = await readOutboxInput(options.input, options.config);
326
+ const text = await readOutboxInput(options.input, options.config, options.safetyPolicy);
323
327
  const events = parseOutboxText(text);
324
328
  const db = openKnowledgeDb(options.dbPath);
325
329
  const runId = `run_${randomUUID()}`;
@@ -350,6 +354,15 @@ export async function consumeOpenFilesOutbox(options: OutboxConsumeOptions): Pro
350
354
  let movedSources = 0;
351
355
  let permissionUpdates = 0;
352
356
 
357
+ recordAuditEvent(db, {
358
+ event_type: 'source_read',
359
+ action: options.input.startsWith('s3://') ? 's3_outbox_read' : 'local_outbox_read',
360
+ target_uri: options.input,
361
+ decision: 'allow',
362
+ metadata: { events: events.length, read_only: true },
363
+ created_at: now,
364
+ });
365
+
353
366
  events.forEach((raw, index) => {
354
367
  const event = normalizeEvent(raw, now);
355
368
  const sourceId = ensureSource(db, event, now);
@@ -404,6 +417,22 @@ export async function consumeOpenFilesOutbox(options: OutboxConsumeOptions): Pro
404
417
  ],
405
418
  );
406
419
 
420
+ recordAuditEvent(db, {
421
+ event_type: 'write',
422
+ action: 'knowledge_outbox_invalidation',
423
+ target_uri: options.dbPath,
424
+ decision: 'allow',
425
+ metadata: {
426
+ run_id: runId,
427
+ events: events.length,
428
+ sources: sourcesTouched.size,
429
+ revisions: revisionsTouched.size,
430
+ chunks_deleted: chunksDeleted,
431
+ embeddings_deleted: embeddingsDeleted,
432
+ },
433
+ created_at: now,
434
+ });
435
+
407
436
  return {
408
437
  path: options.input,
409
438
  db_path: options.dbPath,