@hilbras/omninode 2.0.0-alpha.11 → 2.0.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ existing intelligence; it does not try to become another model.
18
18
 
19
19
  ## Status
20
20
 
21
- **v2.0.0-alpha.11 — v2 Phase 11: Planner v2**
21
+ **v2.0.0-alpha.13 — v2 Phase 13: Security Hardening**
22
22
  (the reliability & interoperability line: architecture → execution →
23
23
  protocol → adapters → pipelines → providers → OmniHilbras → memory → reports) (the v2
24
24
  roadmap is [docs/ROADMAP_V2.md](docs/ROADMAP_V2.md); the v1 plan is complete:
@@ -31,6 +31,20 @@ for the plan and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design).
31
31
 
32
32
  ### What's new in v2 (so far)
33
33
 
34
+ - **Phase 13 — Security Hardening**: configuration containing literal
35
+ credentials is **rejected before parsing** (with a redacted, actionable
36
+ error); reports over 256 KiB are rejected rather than persisted; static
37
+ guards keep shell execution out of the codebase; and `omninode security
38
+ audit` reviews a project's posture (environment exposure, external working
39
+ directories, credential references) and states the agent trust model
40
+ plainly — agents run with your OS user's permissions, and OmniNode is not
41
+ a sandbox.
42
+ - **Phase 12 — Persistence Layer v2**: writes are now **fsynced and atomic**
43
+ (temp → fsync → rename → directory fsync), corrupt store files are
44
+ **quarantined instead of lost**, documents carry a **schema version** with
45
+ v1 data migrated in place (`omninode migrate [--check]`), and files from a
46
+ newer OmniNode are refused rather than misread. Details:
47
+ [PERSISTENCE.md](docs/PERSISTENCE.md).
34
48
  - **Phase 11 — Planner v2**: the planner now receives the full §15 input —
35
49
  task, project context, agent reports, **aggregated findings (consensus and
36
50
  conflicts included)**, memory and **constraints** (settable per pipeline
@@ -359,7 +373,7 @@ required for the core engine.
359
373
  - [Architecture](docs/ARCHITECTURE.md) — module map, workflow, local state
360
374
  - [Security model](docs/SECURITY.md) — secrets, process execution, env isolation, audit log
361
375
  - [API stability policy](docs/API_STABILITY.md) and [public API inventory](docs/API.md)
362
- - [Agent Protocol v2 spec](docs/PROTOCOL.md) · [Providers](docs/PROVIDERS.md) · [Memory](docs/MEMORY.md) · [Reports](docs/REPORTS.md) · [Deprecations](docs/DEPRECATIONS.md) · [Changelog](CHANGELOG.md) · [v2 roadmap](docs/ROADMAP_V2.md)
376
+ - [Agent Protocol v2 spec](docs/PROTOCOL.md) · [Providers](docs/PROVIDERS.md) · [Memory](docs/MEMORY.md) · [Reports](docs/REPORTS.md) · [Persistence](docs/PERSISTENCE.md) · [Deprecations](docs/DEPRECATIONS.md) · [Changelog](CHANGELOG.md) · [v2 roadmap](docs/ROADMAP_V2.md)
363
377
  - [Example project](examples/demo/) — a runnable end-to-end demo (`./demo.sh`)
364
378
 
365
379
  Prefer a container? `docker build -t omninode .` then
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "2.0.0-alpha.11";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.13";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -145,6 +145,39 @@ var appConfigSchema = z.object({
145
145
  logging: z.object({ level: z.enum(["debug", "info", "warn", "error"]) }).strict().optional()
146
146
  }).strict();
147
147
 
148
+ // src/config/secrets.ts
149
+ var SAFE_KEY_SUFFIXES = ["_env", "_env_var", "_file", "_path", "_ref", "_id", "_url", "_command"];
150
+ var SECRET_KEY = /^\s*([A-Za-z0-9_-]*(?:api[_-]?key|token|password|passwd|secret|credential)[A-Za-z0-9_-]*)\s*:\s*(.*)$/i;
151
+ var SAFE_VALUE = /^\s*(?:\$\{[^}]*\}|\$[A-Z_][A-Z0-9_]*|<[^>]*>|""|''|null|~)\s*$/i;
152
+ function scanForInlineSecrets(source) {
153
+ const findings = [];
154
+ source.split("\n").forEach((line, index) => {
155
+ const trimmed = line.trim();
156
+ if (trimmed.startsWith("#")) return;
157
+ const match = SECRET_KEY.exec(line);
158
+ if (!match) return;
159
+ const key = match[1] ?? "";
160
+ const rawValue = (match[2] ?? "").trim().replace(/,\s*$/, "");
161
+ const keyIsReference = SAFE_KEY_SUFFIXES.some((suffix) => key.toLowerCase().endsWith(suffix));
162
+ if (keyIsReference) return;
163
+ const value = rawValue.replace(/^["']|["']$/g, "");
164
+ if (value.length === 0 || SAFE_VALUE.test(rawValue)) return;
165
+ if (value.length < 8) return;
166
+ findings.push({
167
+ line: index + 1,
168
+ key,
169
+ excerpt: `${key}: ${value.slice(0, 3)}\u2026 (${value.length} chars, redacted)`
170
+ });
171
+ });
172
+ return findings;
173
+ }
174
+ function describeSecretFindings(file, findings) {
175
+ const lines = findings.map((f) => ` line ${f.line}: ${f.excerpt}`).join("\n");
176
+ return `${file} appears to contain ${findings.length} inline secret(s):
177
+ ${lines}
178
+ OmniNode never stores credentials in configuration. Replace each value with an environment-variable reference, e.g. api_key_env_var: MY_API_KEY.`;
179
+ }
180
+
148
181
  // src/config/loader.ts
149
182
  import { existsSync, readFileSync } from "fs";
150
183
  import { parse as parseYaml } from "yaml";
@@ -181,6 +214,10 @@ function loadConfig(options = {}) {
181
214
  );
182
215
  }
183
216
  const raw = readFileSync(path, "utf8");
217
+ const secretFindings = scanForInlineSecrets(raw);
218
+ if (secretFindings.length > 0) {
219
+ throw new ConfigError("CONFIG_INVALID", describeSecretFindings(path, secretFindings));
220
+ }
184
221
  const expanded = expandEnvRefs(raw, env, path);
185
222
  let data;
186
223
  try {
@@ -2108,51 +2145,119 @@ function registerRoleCommands(program) {
2108
2145
  }
2109
2146
 
2110
2147
  // src/persistence/json-file-store.ts
2111
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
2148
+ import { open, mkdir, readFile, rename, stat } from "fs/promises";
2112
2149
  import { dirname } from "path";
2150
+ var SCHEMA_VERSION = 2;
2151
+ async function writeFileAtomic(target, contents) {
2152
+ const tmp = `${target}.tmp`;
2153
+ await mkdir(dirname(target), { recursive: true });
2154
+ const handle = await open(tmp, "w");
2155
+ try {
2156
+ await handle.writeFile(contents, "utf8");
2157
+ await handle.sync();
2158
+ } finally {
2159
+ await handle.close();
2160
+ }
2161
+ await rename(tmp, target);
2162
+ try {
2163
+ const dir = await open(dirname(target), "r");
2164
+ try {
2165
+ await dir.sync();
2166
+ } finally {
2167
+ await dir.close();
2168
+ }
2169
+ } catch {
2170
+ }
2171
+ }
2172
+ async function inspectDocument(path) {
2173
+ let raw;
2174
+ try {
2175
+ await stat(path);
2176
+ } catch {
2177
+ return { status: "missing" };
2178
+ }
2179
+ try {
2180
+ raw = await readFile(path, "utf8");
2181
+ } catch (error) {
2182
+ return { status: "corrupt", detail: `unreadable: ${error instanceof Error ? error.message : String(error)}` };
2183
+ }
2184
+ if (raw.trim().length === 0) return { status: "empty" };
2185
+ let parsed;
2186
+ try {
2187
+ parsed = JSON.parse(raw);
2188
+ } catch (error) {
2189
+ return { status: "corrupt", detail: error instanceof Error ? error.message : "invalid JSON" };
2190
+ }
2191
+ if (Array.isArray(parsed)) return { status: "v1-legacy" };
2192
+ if (parsed === null || typeof parsed !== "object") return { status: "corrupt", detail: "not an object" };
2193
+ const doc = parsed;
2194
+ if (!Array.isArray(doc.items)) return { status: "corrupt", detail: "missing items array" };
2195
+ const version = typeof doc.schemaVersion === "number" ? doc.schemaVersion : 1;
2196
+ if (version > SCHEMA_VERSION) return { status: "future", schemaVersion: version };
2197
+ if (version < SCHEMA_VERSION) return { status: "v1-legacy" };
2198
+ return { status: "current", schemaVersion: version, count: doc.items.length };
2199
+ }
2200
+ async function readDocument(path, onCorrupt, quarantine = true) {
2201
+ const state = await inspectDocument(path);
2202
+ if (state.status === "missing" || state.status === "empty") return [];
2203
+ if (state.status === "future") {
2204
+ const error = new Error(
2205
+ `${path} was written by a newer OmniNode (schema v${state.schemaVersion} > v${SCHEMA_VERSION})`
2206
+ );
2207
+ onCorrupt?.(path, error);
2208
+ return [];
2209
+ }
2210
+ let raw;
2211
+ try {
2212
+ raw = await readFile(path, "utf8");
2213
+ } catch (error) {
2214
+ onCorrupt?.(path, error);
2215
+ return [];
2216
+ }
2217
+ try {
2218
+ const parsed = JSON.parse(raw);
2219
+ if (Array.isArray(parsed)) return parsed;
2220
+ return parsed.items ?? [];
2221
+ } catch (error) {
2222
+ if (quarantine) {
2223
+ const quarantinePath = `${path}.corrupt-${Date.now()}`;
2224
+ try {
2225
+ await rename(path, quarantinePath);
2226
+ logger.child({ component: "persistence" }).warn(
2227
+ `Quarantined unreadable store file ${path} \u2192 ${quarantinePath}`
2228
+ );
2229
+ } catch {
2230
+ }
2231
+ }
2232
+ onCorrupt?.(path, error);
2233
+ return [];
2234
+ }
2235
+ }
2113
2236
  var JsonFileStore = class {
2114
2237
  filePath;
2115
2238
  schemaVersion;
2116
2239
  onCorrupt;
2240
+ quarantine;
2117
2241
  log;
2118
2242
  queue = Promise.resolve();
2119
2243
  constructor(options) {
2120
2244
  this.filePath = `${options.directory ?? `${process.cwd()}/.omninode`}/${options.fileName}`;
2121
- this.schemaVersion = options.schemaVersion ?? 1;
2245
+ this.schemaVersion = options.schemaVersion ?? SCHEMA_VERSION;
2122
2246
  this.onCorrupt = options.onCorrupt;
2247
+ this.quarantine = options.quarantine ?? true;
2123
2248
  this.log = logger.child({ component: "persistence", file: options.fileName });
2124
2249
  }
2125
2250
  get path() {
2126
2251
  return this.filePath;
2127
2252
  }
2128
- /** All items, or [] when missing/corrupt. */
2253
+ /** All items, or [] when missing/migrated-from-v1/corrupt. */
2129
2254
  async readAll() {
2130
- let raw;
2131
- try {
2132
- raw = await readFile(this.filePath, "utf8");
2133
- } catch {
2134
- return [];
2135
- }
2136
- try {
2137
- const parsed = JSON.parse(raw);
2138
- if (Array.isArray(parsed)) return parsed;
2139
- if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.items)) {
2140
- return parsed.items;
2141
- }
2142
- throw new Error("persisted file has an unrecognized shape");
2143
- } catch (error) {
2144
- this.log.warn(`Treating ${this.filePath} as empty \u2014 it could not be parsed.`);
2145
- this.onCorrupt?.(this.filePath, error);
2146
- return [];
2147
- }
2255
+ return readDocument(this.filePath, this.onCorrupt, this.quarantine);
2148
2256
  }
2149
2257
  async writeAll(items) {
2150
2258
  const document = { schemaVersion: this.schemaVersion, items };
2151
- const tmp = `${this.filePath}.tmp`;
2152
- await mkdir(dirname(this.filePath), { recursive: true });
2153
- await writeFile(tmp, `${JSON.stringify(document, null, 2)}
2154
- `, "utf8");
2155
- await rename(tmp, this.filePath);
2259
+ await writeFileAtomic(this.filePath, `${JSON.stringify(document, null, 2)}
2260
+ `);
2156
2261
  }
2157
2262
  /** Serializes an async operation against this file. */
2158
2263
  synchronized(operation) {
@@ -2455,7 +2560,7 @@ function sleep(ms) {
2455
2560
  }
2456
2561
 
2457
2562
  // src/audit/index.ts
2458
- import { appendFile, mkdir as mkdir2 } from "fs/promises";
2563
+ import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
2459
2564
  import { dirname as dirname2 } from "path";
2460
2565
  var FileAuditLog = class {
2461
2566
  filePath;
@@ -2467,8 +2572,15 @@ var FileAuditLog = class {
2467
2572
  async record(event) {
2468
2573
  try {
2469
2574
  await mkdir2(dirname2(this.filePath), { recursive: true });
2470
- await appendFile(this.filePath, `${JSON.stringify(event)}
2575
+ const { open: open2 } = await import("fs/promises");
2576
+ const handle = await open2(this.filePath, "a");
2577
+ try {
2578
+ await handle.appendFile(`${JSON.stringify(event)}
2471
2579
  `, "utf8");
2580
+ await handle.sync();
2581
+ } finally {
2582
+ await handle.close();
2583
+ }
2472
2584
  } catch (error) {
2473
2585
  this.log.warn(
2474
2586
  `Audit write failed: ${error instanceof Error ? error.message : String(error)}`
@@ -2477,7 +2589,6 @@ var FileAuditLog = class {
2477
2589
  }
2478
2590
  /** Recent events, oldest first; used by the CLI. */
2479
2591
  async recent(limit = 20) {
2480
- const { readFile: readFile2 } = await import("fs/promises");
2481
2592
  let raw;
2482
2593
  try {
2483
2594
  raw = await readFile2(this.filePath, "utf8");
@@ -3310,6 +3421,7 @@ function truncate4(text, max) {
3310
3421
  }
3311
3422
 
3312
3423
  // src/reports/service.ts
3424
+ var MAX_REPORT_BYTES = 256 * 1024;
3313
3425
  var ReportService = class {
3314
3426
  constructor(store, log = logger) {
3315
3427
  this.store = store;
@@ -3344,6 +3456,16 @@ var ReportService = class {
3344
3456
  const reports = [];
3345
3457
  const rejected = [];
3346
3458
  for (const candidate of candidates) {
3459
+ const size = Buffer.byteLength(JSON.stringify(candidate), "utf8");
3460
+ if (size > MAX_REPORT_BYTES) {
3461
+ rejected.push({
3462
+ key: candidate?.id ?? "(unknown)",
3463
+ reason: `report exceeds the ${MAX_REPORT_BYTES} byte limit (${size} bytes)`,
3464
+ issues: []
3465
+ });
3466
+ this.log.warn(`Rejected oversized report (${size} bytes).`);
3467
+ continue;
3468
+ }
3347
3469
  const result = validateReport(candidate);
3348
3470
  if (result.valid) {
3349
3471
  reports.push(result.report);
@@ -4809,6 +4931,144 @@ function registerAuditCommand(program) {
4809
4931
  });
4810
4932
  }
4811
4933
 
4934
+ // src/persistence/index.ts
4935
+ var STORE_FILES = [
4936
+ "tasks.json",
4937
+ "pipelines.json",
4938
+ "reports.json",
4939
+ "plans.json",
4940
+ "memory.json"
4941
+ ];
4942
+ async function migrateProjectStores(directory = `${process.cwd()}/.omninode`, options = {}) {
4943
+ const reports = [];
4944
+ for (const file of STORE_FILES) {
4945
+ const path = `${directory}/${file}`;
4946
+ const state = await inspectDocument(path);
4947
+ if (state.status === "missing") {
4948
+ reports.push({ file, state: state.status, migrated: false });
4949
+ continue;
4950
+ }
4951
+ if (state.status === "current") {
4952
+ reports.push({ file, state: state.status, before: state.count, after: state.count, migrated: false });
4953
+ continue;
4954
+ }
4955
+ if (state.status === "future") {
4956
+ reports.push({
4957
+ file,
4958
+ state: state.status,
4959
+ migrated: false,
4960
+ note: `written by a newer OmniNode (schema v${state.schemaVersion}); not touched`
4961
+ });
4962
+ continue;
4963
+ }
4964
+ if (state.status === "corrupt") {
4965
+ reports.push({ file, state: state.status, migrated: false, note: state.detail });
4966
+ continue;
4967
+ }
4968
+ const items = await readDocument(path, void 0, false);
4969
+ if (!options.checkOnly) {
4970
+ await writeFileAtomic(path, `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, items }, null, 2)}
4971
+ `);
4972
+ }
4973
+ const beforeCount = items.length;
4974
+ reports.push({
4975
+ file,
4976
+ state: state.status,
4977
+ before: beforeCount,
4978
+ after: items.length,
4979
+ migrated: !options.checkOnly,
4980
+ ...options.checkOnly ? { note: "would migrate" } : {}
4981
+ });
4982
+ }
4983
+ return reports;
4984
+ }
4985
+
4986
+ // src/cli/commands/migrate.ts
4987
+ function registerMigrateCommand(program) {
4988
+ program.command("migrate").description(
4989
+ `Inspect and upgrade persisted store files to schema v${SCHEMA_VERSION} (v1 data is migrated in place).`
4990
+ ).option("--check", "Only report what would change; do not write.").action(async (options) => {
4991
+ const reports = await migrateProjectStores(`${process.cwd()}/.omninode`, {
4992
+ checkOnly: options.check ?? false
4993
+ });
4994
+ let changed = 0;
4995
+ for (const report of reports) {
4996
+ if (report.migrated || report.state === "v1-legacy") changed += 1;
4997
+ const detail = report.state === "corrupt" ? ` (corrupt: ${report.note})` : report.state === "future" ? ` (${report.note})` : report.state === "v1-legacy" ? ` ${report.migrated ? "migrated" : "would migrate"} ${report.before ?? 0} \u2192 ${report.after ?? 0} item(s)` : report.state === "current" ? ` ${report.after ?? 0} item(s)` : "";
4998
+ console.log(`${report.file.padEnd(16)} ${report.state}${detail}`);
4999
+ }
5000
+ console.log(
5001
+ changed === 0 ? `All stores are at schema v${SCHEMA_VERSION}.` : `${options.check ? "Would migrate" : "Migrated"} ${changed} store file(s) to schema v${SCHEMA_VERSION}.`
5002
+ );
5003
+ });
5004
+ }
5005
+
5006
+ // src/cli/commands/security.ts
5007
+ function registerSecurityCommand(program) {
5008
+ program.command("security").description("Inspect a project's security posture (advisory).").argument("[action]", "Action to perform.", "audit").action(async (action) => {
5009
+ if (action !== "audit") {
5010
+ console.log(`Unknown security action "${action}". Available: audit.`);
5011
+ return;
5012
+ }
5013
+ const config = loadConfig();
5014
+ const findings = [];
5015
+ for (const agent of config.project.agents) {
5016
+ const policy = resolveEnvPolicy(agent);
5017
+ if (policy === "inherit") {
5018
+ findings.push({
5019
+ severity: "warn",
5020
+ subject: `agent "${agent.name}"`,
5021
+ message: "inherits the full environment \u2014 provider keys in OmniNode's environment are visible to it. Consider env_policy: allowlist|denylist|explicit."
5022
+ });
5023
+ }
5024
+ if (agent.allowExternalCwd) {
5025
+ findings.push({
5026
+ severity: "info",
5027
+ subject: `agent "${agent.name}"`,
5028
+ message: "may run outside the project root (allow_external_cwd)."
5029
+ });
5030
+ }
5031
+ if (agent.env && Object.keys(agent.env).length > 0) {
5032
+ findings.push({
5033
+ severity: "info",
5034
+ subject: `agent "${agent.name}"`,
5035
+ message: `receives ${Object.keys(agent.env).length} explicit environment variable(s).`
5036
+ });
5037
+ }
5038
+ }
5039
+ for (const provider of config.project.providers) {
5040
+ if (!provider.apiKeyEnvVar) {
5041
+ findings.push({
5042
+ severity: "info",
5043
+ subject: `provider "${provider.name}"`,
5044
+ message: "no api_key_env_var \u2014 fine for keyless local endpoints, otherwise credentials cannot be resolved."
5045
+ });
5046
+ }
5047
+ }
5048
+ if (config.project.memory?.provider === "remembera" && !config.project.memory.apiKeyEnvVar) {
5049
+ findings.push({
5050
+ severity: "warn",
5051
+ subject: "memory",
5052
+ message: "Remembera is configured without api_key_env_var; requests will be unauthenticated."
5053
+ });
5054
+ }
5055
+ console.log(`OmniNode security audit \u2014 project "${config.project.name}"`);
5056
+ if (findings.length === 0) {
5057
+ console.log(" no findings.");
5058
+ }
5059
+ for (const finding of findings) {
5060
+ console.log(` [${finding.severity.toUpperCase()}] ${finding.subject}: ${finding.message}`);
5061
+ }
5062
+ console.log(
5063
+ `
5064
+ Trust model: agents execute with the permissions of the OS user running OmniNode.`
5065
+ );
5066
+ console.log(
5067
+ `OmniNode does not sandbox agents \u2014 run untrusted agents in a container or as a dedicated user.`
5068
+ );
5069
+ });
5070
+ }
5071
+
4812
5072
  // src/cli/index.ts
4813
5073
  function createProgram() {
4814
5074
  const program = new Command();
@@ -4828,6 +5088,8 @@ function createProgram() {
4828
5088
  registerPlanCommands(program);
4829
5089
  registerStatusCommand(program);
4830
5090
  registerAuditCommand(program);
5091
+ registerMigrateCommand(program);
5092
+ registerSecurityCommand(program);
4831
5093
  registerRunAlias(program);
4832
5094
  return program;
4833
5095
  }