@hasna/knowledge 0.2.6 → 0.2.8

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,13 @@
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 { resolveOpenFilesSource } from './source-resolver';
15
+ import { approvalStatus, assertS3ReadAllowed, assertWebSearchAllowed, createApprovalGate, recordAuditEvent, recordRedactionFindings, redactSecrets, resolveSafetyPolicy } from './safety';
14
16
  import pkg from '../package.json' with { type: 'json' };
15
17
 
16
18
  type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@@ -48,6 +50,7 @@ interface Flags {
48
50
  tag?: string;
49
51
  format?: string;
50
52
  completions?: string;
53
+ purpose?: string;
51
54
  noColor?: boolean;
52
55
  scope?: string;
53
56
  olderThan?: number;
@@ -61,7 +64,7 @@ interface ParseResult {
61
64
  flags: Flags;
62
65
  }
63
66
 
64
- const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'ingest', 'reindex', 'help'];
67
+ const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'source', 'ingest', 'reindex', 'safety', 'help'];
65
68
  const COMMAND_ALIASES: Record<string, string> = {
66
69
  ls: 'list',
67
70
  rm: 'delete',
@@ -96,6 +99,7 @@ function parseArgs(argv: string[]): ParseResult {
96
99
  case '--tag': case '-t': flags.tag = argv[i + 1]; i += 1; break;
97
100
  case '--format': flags.format = argv[i + 1]; i += 1; break;
98
101
  case '--completions': flags.completions = argv[i + 1]; i += 1; break;
102
+ case '--purpose': flags.purpose = argv[i + 1]; i += 1; break;
99
103
  case '--no-color': flags.noColor = true; break;
100
104
  case '--scope': flags.scope = argv[i + 1]; i += 1; break;
101
105
  case '--older-than': flags.olderThan = Number(argv[i + 1]); i += 1; break;
@@ -164,13 +168,16 @@ Commands:
164
168
  paths Show resolved workspace/store paths
165
169
  db init|stats Initialize or inspect local knowledge.db
166
170
  wiki init Initialize scalable wiki/schema/index/log artifacts
171
+ source resolve <source-ref> Resolve read-only source content and citation evidence
167
172
  ingest manifest <file|s3://> Ingest an open-files manifest into knowledge.db
168
173
  reindex outbox <file|s3://> Consume open-files change events and invalidate chunks
174
+ safety status|check|approve|audit|redact
169
175
  help [command] Show help
170
176
 
171
177
  Global Options:
172
178
  --json Output JSON
173
179
  --store <path> Override store path
180
+ --purpose <name> Read-only source purpose (default: knowledge_answer)
174
181
  --scope local|global|project Store scope (default: global ~/.hasna/apps/knowledge/)
175
182
  --no-color Disable color output
176
183
  --completions <shell> Output completions for bash|zsh|fish
@@ -227,8 +234,10 @@ function printCommandHelp(command: string): void {
227
234
  if (command === 'paths') { console.log('Usage: open-knowledge paths [--scope local|global|project] [--json]'); return; }
228
235
  if (command === 'db') { console.log('Usage: open-knowledge db init|stats [--scope local|global|project] [--json]'); return; }
229
236
  if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
237
+ if (command === 'source') { console.log('Usage: open-knowledge source resolve <source-ref> [--purpose knowledge_answer|knowledge_index] [--limit <n>] [--scope local|global|project] [--json]'); return; }
230
238
  if (command === 'ingest') { console.log('Usage: open-knowledge ingest manifest <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
231
239
  if (command === 'reindex') { console.log('Usage: open-knowledge reindex outbox <file|s3://bucket/key> [--scope local|global|project] [--json]'); return; }
240
+ if (command === 'safety') { console.log('Usage: open-knowledge safety status|check|approve|audit|redact [args] [--scope local|global|project] [--json]'); return; }
232
241
  printGlobalHelp();
233
242
  }
234
243
 
@@ -273,11 +282,11 @@ async function run(argv: string[]): Promise<void> {
273
282
  if (flags.completions) {
274
283
  const shell = flags.completions;
275
284
  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`);
285
+ 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 source 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 --purpose --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
277
286
  } 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`);
287
+ 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 source 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):" "(--purpose)--purpose[purpose]:" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
279
288
  } 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"`);
289
+ 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 source 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 purpose; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
281
290
  } else {
282
291
  throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
283
292
  }
@@ -347,6 +356,157 @@ async function run(argv: string[]): Promise<void> {
347
356
  return;
348
357
  }
349
358
 
359
+ if (command === 'safety') {
360
+ const action = positional[1] ?? 'status';
361
+ const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
362
+ const config = readKnowledgeConfig(resolvedWorkspace.configPath);
363
+ const policy = resolveSafetyPolicy(config, resolvedWorkspace);
364
+ migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
365
+ const db = openKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
366
+ try {
367
+ if (action === 'status') {
368
+ output({
369
+ ok: true,
370
+ mode: policy.mode,
371
+ workspace: resolvedWorkspace.home,
372
+ allow_write_roots: policy.allowWriteRoots,
373
+ read_only_source_access: policy.readOnlySourceAccess,
374
+ network: policy.network,
375
+ redaction: policy.redaction,
376
+ approvals: policy.approvals,
377
+ message: `Safety policy: ${policy.mode}`,
378
+ }, flags.json);
379
+ return;
380
+ }
381
+ if (action === 'check') {
382
+ const checkAction = positional[2] ?? 'generated_write';
383
+ const target = positional[3] ?? null;
384
+ let decision: ReturnType<typeof approvalStatus> | { action: string; target_uri: string | null; approval_required: false; approved: boolean; decision: string };
385
+ try {
386
+ if (checkAction === 'web_search') {
387
+ assertWebSearchAllowed(policy);
388
+ decision = { action: checkAction, target_uri: target, approval_required: false, approved: true, decision: 'allow' };
389
+ } else if (checkAction === 's3_read') {
390
+ if (!target) throw new Error('safety check s3_read requires an s3:// target.');
391
+ assertS3ReadAllowed(target, policy);
392
+ decision = { action: checkAction, target_uri: target, approval_required: false, approved: true, decision: 'allow' };
393
+ } else {
394
+ decision = approvalStatus(db, policy, checkAction, target);
395
+ }
396
+ recordAuditEvent(db, {
397
+ event_type: 'safety_check',
398
+ action: checkAction,
399
+ target_uri: target,
400
+ decision: decision.decision === 'allow' ? 'allow' : 'requires_approval',
401
+ metadata: decision,
402
+ });
403
+ output({ ok: true, ...decision, message: `Safety check ${decision.decision}` }, flags.json);
404
+ return;
405
+ } catch (error) {
406
+ recordAuditEvent(db, {
407
+ event_type: 'safety_check',
408
+ action: checkAction,
409
+ target_uri: target,
410
+ decision: 'deny',
411
+ metadata: { error: error instanceof Error ? error.message : String(error) },
412
+ });
413
+ throw error;
414
+ }
415
+ }
416
+ if (action === 'approve') {
417
+ const approveAction = positional[2] ?? 'generated_write';
418
+ const target = positional[3] ?? null;
419
+ const approval = createApprovalGate(db, {
420
+ action: approveAction,
421
+ target_uri: target,
422
+ reason: 'local-cli approval',
423
+ metadata: { scope: flags.scope ?? 'global' },
424
+ });
425
+ recordAuditEvent(db, {
426
+ event_type: 'approval',
427
+ action: approveAction,
428
+ target_uri: target,
429
+ decision: 'allow',
430
+ metadata: { approval_id: approval.id },
431
+ });
432
+ output({ ok: true, ...approval, action: approveAction, target_uri: target, message: `Approved ${approveAction}` }, flags.json);
433
+ return;
434
+ }
435
+ if (action === 'audit') {
436
+ const rows = db.query<{
437
+ id: string;
438
+ event_type: string;
439
+ action: string;
440
+ target_uri: string | null;
441
+ decision: string;
442
+ metadata_json: string;
443
+ created_at: string;
444
+ }, []>(
445
+ 'SELECT id, event_type, action, target_uri, decision, metadata_json, created_at FROM audit_events ORDER BY created_at DESC LIMIT 50',
446
+ ).all().map((row) => ({
447
+ id: row.id,
448
+ event_type: row.event_type,
449
+ action: row.action,
450
+ target_uri: row.target_uri,
451
+ decision: row.decision,
452
+ metadata: JSON.parse(row.metadata_json),
453
+ created_at: row.created_at,
454
+ }));
455
+ output({ ok: true, events: rows, message: `${rows.length} audit event(s)` }, flags.json);
456
+ return;
457
+ }
458
+ if (action === 'redact') {
459
+ const text = positional.slice(2).join(' ');
460
+ if (!text) throw new Error('Usage: open-knowledge safety redact <text>');
461
+ const result = redactSecrets(text, policy);
462
+ if (result.findings.length > 0) {
463
+ recordRedactionFindings(db, {
464
+ source_uri: 'safety://redact',
465
+ findings: result.findings,
466
+ metadata: { command: 'safety redact' },
467
+ });
468
+ }
469
+ recordAuditEvent(db, {
470
+ event_type: 'redaction',
471
+ action: 'safety_redact',
472
+ target_uri: 'safety://redact',
473
+ decision: result.findings.length > 0 ? 'redacted' : 'allow',
474
+ metadata: { findings: result.findings.length },
475
+ });
476
+ output({ ok: true, text: result.text, findings: result.findings, message: `Redacted ${result.findings.length} finding(s)` }, flags.json);
477
+ return;
478
+ }
479
+ throw new Error("Invalid safety action. Use 'status', 'check', 'approve', 'audit', or 'redact'.");
480
+ } finally {
481
+ db.close();
482
+ }
483
+ }
484
+
485
+ if (command === 'source') {
486
+ const action = positional[1] ?? '';
487
+ if (action !== 'resolve') throw new Error("Invalid source action. Use 'resolve'.");
488
+ const sourceRef = positional[2];
489
+ if (!sourceRef) throw new Error('Usage: open-knowledge source resolve <source-ref>');
490
+ const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
491
+ const config = readKnowledgeConfig(resolvedWorkspace.configPath);
492
+ const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
493
+ const result = await resolveOpenFilesSource({
494
+ dbPath: resolvedWorkspace.knowledgeDbPath,
495
+ sourceRef,
496
+ purpose: flags.purpose,
497
+ limit: flags.limit,
498
+ safetyPolicy,
499
+ });
500
+ output({
501
+ ok: true,
502
+ ...result,
503
+ message: result.resolved
504
+ ? `Resolved ${result.source_ref} (${result.content.chunks_returned}/${result.content.chunks_total} chunks)`
505
+ : `Source not indexed: ${sourceRef}`,
506
+ }, flags.json);
507
+ return;
508
+ }
509
+
350
510
  if (command === 'ingest') {
351
511
  const action = positional[1] ?? '';
352
512
  if (action !== 'manifest') throw new Error("Invalid ingest action. Use 'manifest'.");
@@ -354,10 +514,12 @@ async function run(argv: string[]): Promise<void> {
354
514
  if (!input) throw new Error('Usage: open-knowledge ingest manifest <file|s3://bucket/key>');
355
515
  const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
356
516
  const config = readKnowledgeConfig(resolvedWorkspace.configPath);
517
+ const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
357
518
  const result = await ingestOpenFilesManifest({
358
519
  dbPath: resolvedWorkspace.knowledgeDbPath,
359
520
  input,
360
521
  config,
522
+ safetyPolicy,
361
523
  });
362
524
  output({ ok: true, ...result, message: `Ingested ${result.items_seen} manifest item(s)` }, flags.json);
363
525
  return;
@@ -370,10 +532,12 @@ async function run(argv: string[]): Promise<void> {
370
532
  if (!input) throw new Error('Usage: open-knowledge reindex outbox <file|s3://bucket/key>');
371
533
  const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
372
534
  const config = readKnowledgeConfig(resolvedWorkspace.configPath);
535
+ const safetyPolicy = resolveSafetyPolicy(config, resolvedWorkspace);
373
536
  const result = await consumeOpenFilesOutbox({
374
537
  dbPath: resolvedWorkspace.knowledgeDbPath,
375
538
  input,
376
539
  config,
540
+ safetyPolicy,
377
541
  });
378
542
  output({ ok: true, ...result, message: `Consumed ${result.events_seen} outbox event(s)` }, flags.json);
379
543
  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
  })();
package/src/mcp.js CHANGED
@@ -7,6 +7,8 @@ import pkg from '../package.json' with { type: 'json' };
7
7
  import { defaultStorePath, loadStore, saveStore, makeId, withLock } from './store.ts';
8
8
  import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace.ts';
9
9
  import { parseSourceRef } from './source-ref.ts';
10
+ import { resolveOpenFilesSource } from './source-resolver.ts';
11
+ import { resolveSafetyPolicy } from './safety.ts';
10
12
 
11
13
  const storePathField = z.string().optional().describe('Path to the JSON store file');
12
14
  const scopeField = z.enum(['local', 'global', 'project']).optional().describe('Workspace scope');
@@ -102,6 +104,29 @@ export function buildServer() {
102
104
  }
103
105
  });
104
106
 
107
+ registerTool(server, 'ok_resolve_source', 'Resolve source content', 'Resolve an indexed source ref through the read-only open-files boundary and return chunk citation evidence', {
108
+ source_ref: z.string().describe('Source reference URI, preferably open-files://...'),
109
+ purpose: z.string().optional().describe('Read-only purpose label, default knowledge_answer'),
110
+ limit: z.number().optional().describe('Maximum chunks to return, default 10'),
111
+ scope: scopeField,
112
+ }, async ({ source_ref, purpose, limit, scope }) => {
113
+ const workspace = ensureKnowledgeWorkspace(resolveScopedWorkspace(scope).home);
114
+ const config = readKnowledgeConfig(workspace.configPath);
115
+ const safetyPolicy = resolveSafetyPolicy(config, workspace);
116
+ try {
117
+ const result = await resolveOpenFilesSource({
118
+ dbPath: workspace.knowledgeDbPath,
119
+ sourceRef: source_ref,
120
+ purpose,
121
+ limit,
122
+ safetyPolicy,
123
+ });
124
+ return jsonText({ ok: true, ...result });
125
+ } catch (error) {
126
+ return errorText(error instanceof Error ? error.message : String(error));
127
+ }
128
+ });
129
+
105
130
  registerTool(server, 'ok_add', 'Add a knowledge item', 'Add a new item to the knowledge store', {
106
131
  title: z.string().describe('Item title'),
107
132
  content: z.string().describe('Item content/body'),
@@ -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,