@semalt-ai/code 1.8.5 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
package/lib/config.js CHANGED
@@ -4,7 +4,11 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { URL } = require('url');
6
6
 
7
- const { CONFIG_PATH, DEFAULT_CONFIG } = require('./constants');
7
+ const { CONFIG_PATH, DEFAULT_CONFIG, DEFAULT_MAX_ITERATIONS, DEFAULT_WEB_MAX_CONTENT_TOKENS, DEFAULT_USER_AGENT, DEFAULT_MCP_MAX_RESULT_TOKENS, DEFAULT_SUBAGENT_MAX_RESULT_TOKENS } = require('./constants');
8
+ const { normalizeHooks, loadHookLayers } = require('./hooks');
9
+ const { normalizeVerify, loadVerifyLayers } = require('./verify');
10
+ const { normalizeCheckpoints } = require('./checkpoints');
11
+ const { normalizeSandbox } = require('./sandbox');
8
12
 
9
13
  let _apiKeyAnyWarned = false;
10
14
  const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
@@ -12,6 +16,13 @@ const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
12
16
  function _maybeWarnApiKeyAny(cfg) {
13
17
  if (_apiKeyAnyWarned) return;
14
18
  if (cfg.api_key !== 'any') return;
19
+ // A real key from SEMALT_API_KEY or the OS keychain overrides config.api_key,
20
+ // so the 'any' placeholder here is harmless — don't warn in that case.
21
+ try {
22
+ const { apiKeySource } = require('./secrets');
23
+ const src = apiKeySource(cfg);
24
+ if (src === 'env' || src === 'keychain') return;
25
+ } catch {}
15
26
  let host = '';
16
27
  try {
17
28
  host = new URL(cfg.api_base).hostname;
@@ -27,6 +38,25 @@ function _maybeWarnApiKeyAny(cfg) {
27
38
  );
28
39
  }
29
40
 
41
+ // Canonicalize a raw max_iterations value (from config or a flag layer) to the
42
+ // stored form: a positive integer, or 0 for "unlimited". Invalid input falls
43
+ // back to the default. Kept JSON-serializable — Infinity is never stored.
44
+ function normalizeMaxIterations(raw) {
45
+ if (raw === 'unlimited') return 0;
46
+ const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
47
+ if (n === 0) return 0;
48
+ if (Number.isInteger(n) && n > 0) return n;
49
+ return DEFAULT_MAX_ITERATIONS;
50
+ }
51
+
52
+ // Convert a normalized config max_iterations value into the numeric cap the
53
+ // agent loop consumes. The 0 sentinel maps to Infinity (deliberately unbounded);
54
+ // a positive integer is the cap; anything unexpected falls back to the default.
55
+ function resolveMaxIterations(value) {
56
+ const n = normalizeMaxIterations(value);
57
+ return n === 0 ? Infinity : n;
58
+ }
59
+
30
60
  function normalizeConfig(cfg = {}) {
31
61
  const merged = { ...DEFAULT_CONFIG, ...cfg };
32
62
  // Ensure every DEFAULT_CONFIG key is present without overwriting existing values
@@ -56,6 +86,103 @@ function normalizeConfig(cfg = {}) {
56
86
  ? cfg.dashboard_model_id
57
87
  : null;
58
88
  merged.repair_malformed_tool_xml = cfg.repair_malformed_tool_xml === true;
89
+ // Max agent-loop iterations per turn (Pre-Task 4.0a). Canonical form is a
90
+ // number: a positive integer caps the loop; 0 is the "unlimited" sentinel
91
+ // (config.json cannot store Infinity). Accept 'unlimited'/'0' strings and
92
+ // numeric strings; anything else (negative, fractional, garbage) falls back to
93
+ // the documented default.
94
+ merged.max_iterations = normalizeMaxIterations(cfg.max_iterations);
95
+ // Proxy intent (Task 2.2): carried through as strings; sourced from the env
96
+ // config layer (HTTPS_PROXY/HTTP_PROXY). '' means none.
97
+ merged.https_proxy = typeof merged.https_proxy === 'string' ? merged.https_proxy : '';
98
+ merged.http_proxy = typeof merged.http_proxy === 'string' ? merged.http_proxy : '';
99
+ // Cost price overrides (Task 2.6): a plain object map of model → { input, output }.
100
+ merged.pricing = (cfg.pricing && typeof cfg.pricing === 'object' && !Array.isArray(cfg.pricing))
101
+ ? cfg.pricing
102
+ : {};
103
+ // Multimodal image input (Task 5.4). `image_max_bytes` is a positive integer
104
+ // byte cap (raw bytes, pre-base64); anything invalid falls back to the default.
105
+ // `image_format` forces the provider content-part shape; only 'anthropic' /
106
+ // 'openai' are honored, else '' (heuristic selection).
107
+ merged.image_max_bytes = (Number.isInteger(cfg.image_max_bytes) && cfg.image_max_bytes > 0)
108
+ ? cfg.image_max_bytes
109
+ : DEFAULT_CONFIG.image_max_bytes;
110
+ merged.image_format = (cfg.image_format === 'anthropic' || cfg.image_format === 'openai')
111
+ ? cfg.image_format
112
+ : '';
113
+ // Task 2.7 payload flags.
114
+ merged.prompt_caching = cfg.prompt_caching === true;
115
+ merged.reasoning_effort_force = cfg.reasoning_effort_force === true;
116
+ merged.reasoning_effort = ['minimal', 'low', 'medium', 'high'].includes(cfg.reasoning_effort)
117
+ ? cfg.reasoning_effort
118
+ : '';
119
+ // MCP config scaffold (Task 3.2). Always normalized to an object with a
120
+ // `servers` object map, so Task 3.3 can read `cfg.mcp.servers` unconditionally.
121
+ const mcpIn = (cfg.mcp && typeof cfg.mcp === 'object' && !Array.isArray(cfg.mcp)) ? cfg.mcp : {};
122
+ // max_result_tokens (Task W.8): the STRICTER token cap applied to an MCP tool
123
+ // result before it enters context (third-party / untrusted). A positive integer
124
+ // wins; anything else falls back to the default.
125
+ let _mcpResultTok = parseInt(mcpIn.max_result_tokens, 10);
126
+ if (!Number.isInteger(_mcpResultTok) || _mcpResultTok < 1) _mcpResultTok = DEFAULT_MCP_MAX_RESULT_TOKENS;
127
+ merged.mcp = {
128
+ servers: (mcpIn.servers && typeof mcpIn.servers === 'object' && !Array.isArray(mcpIn.servers))
129
+ ? mcpIn.servers
130
+ : {},
131
+ max_result_tokens: _mcpResultTok,
132
+ };
133
+ // Lifecycle hooks (Task 3.4). Always normalized to a map with one array per
134
+ // known event, so dispatch in lib/agent.js / lib/hooks.js can read
135
+ // cfg.hooks[event] unconditionally. Empty by default.
136
+ merged.hooks = normalizeHooks(cfg.hooks);
137
+ // Subagents (Task 3.6). Always normalized to an object carrying the bounded
138
+ // parallel-execution cap, so lib/subagents.js can read it unconditionally.
139
+ const subIn = (cfg.subagents && typeof cfg.subagents === 'object' && !Array.isArray(cfg.subagents)) ? cfg.subagents : {};
140
+ let _maxConc = parseInt(subIn.max_concurrency, 10);
141
+ if (!Number.isInteger(_maxConc) || _maxConc < 1) _maxConc = 3;
142
+ if (_maxConc > 16) _maxConc = 16;
143
+ // max_result_tokens (Task W.8): the GENEROUS token cap on a subagent's final
144
+ // text before it enters the parent context (our own child's synthesized answer
145
+ // — a safety net against a verbose child, strictly larger than the MCP cap).
146
+ let _subResultTok = parseInt(subIn.max_result_tokens, 10);
147
+ if (!Number.isInteger(_subResultTok) || _subResultTok < 1) _subResultTok = DEFAULT_SUBAGENT_MAX_RESULT_TOKENS;
148
+ merged.subagents = { max_concurrency: _maxConc, max_result_tokens: _subResultTok };
149
+ // Per-pattern permission rules (Task 4.1). Normalized to a shape-only
150
+ // `{ rules: [...] }` here — the security-critical per-LAYER validation and
151
+ // compilation lives in lib/permission-rules.js (loadRuleLayers), which reads
152
+ // the user/project files independently so the project layer can only narrow.
153
+ const permIn = (cfg.permissions && typeof cfg.permissions === 'object' && !Array.isArray(cfg.permissions)) ? cfg.permissions : {};
154
+ merged.permissions = { rules: Array.isArray(permIn.rules) ? permIn.rules : [] };
155
+ // Self-verification (Task 4.2). Always normalized to a complete verify spec so
156
+ // lib/verify.js / lib/agent.js can read cfg.verify unconditionally. Empty
157
+ // command by default → the feature is a no-op until the user configures one.
158
+ merged.verify = normalizeVerify(cfg.verify);
159
+ // Checkpoints & rewind (Task 4.3). Always normalized to a complete spec so
160
+ // lib/checkpoints.js can read cfg.checkpoints unconditionally. Enabled by
161
+ // default; bounded by a per-file size cap and a per-session retention cap.
162
+ merged.checkpoints = normalizeCheckpoints(cfg.checkpoints);
163
+ // OS sandbox (Task 4.4). Always normalized so lib/sandbox.js / lib/tools.js can
164
+ // read cfg.sandbox unconditionally. `auto` by default; `off` is a human-only
165
+ // opt-out (no model-reachable path can set it — config files are human-edited).
166
+ merged.sandbox = normalizeSandbox(cfg.sandbox);
167
+ // Web-fetch pipeline (Task W.1). Always normalized so http_get can read
168
+ // cfg.web unconditionally. `summarize` defaults ON (the big token win); only
169
+ // an explicit `false` opts out. `summary_model` is a trimmed string ('' →
170
+ // current model). `max_content_tokens` is a sane positive integer (clamped),
171
+ // the cap on extracted content fed to the summarizer / context.
172
+ const webIn = (cfg.web && typeof cfg.web === 'object' && !Array.isArray(cfg.web)) ? cfg.web : {};
173
+ let _webMaxTok = parseInt(webIn.max_content_tokens, 10);
174
+ if (!Number.isInteger(_webMaxTok) || _webMaxTok < 256) _webMaxTok = DEFAULT_WEB_MAX_CONTENT_TOKENS;
175
+ if (_webMaxTok > 200000) _webMaxTok = 200000;
176
+ merged.web = {
177
+ summarize: webIn.summarize === false ? false : true,
178
+ summary_model: typeof webIn.summary_model === 'string' ? webIn.summary_model.trim() : '',
179
+ max_content_tokens: _webMaxTok,
180
+ // User-Agent for http_get/download (Task W.3 Part 2). A trimmed operator
181
+ // override; empty/missing → the fixed DEFAULT_USER_AGENT. Human-only — the
182
+ // agent has no way to set it (no UA parameter in the tool spec).
183
+ user_agent: typeof webIn.user_agent === 'string' && webIn.user_agent.trim()
184
+ ? webIn.user_agent.trim() : DEFAULT_USER_AGENT,
185
+ };
59
186
  merged.models = Array.isArray(cfg.models)
60
187
  ? cfg.models
61
188
  .filter((entry) => entry &&
@@ -79,22 +206,188 @@ function normalizeConfig(cfg = {}) {
79
206
  // native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
80
207
  const nt = entry.native_tools;
81
208
  normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
209
+ // Multimodal image input (Task 5.4). `vision` (only when an explicit
210
+ // boolean) marks the profile vision-capable or text-only; a text-only
211
+ // profile makes an image attach fail LOUD rather than silently drop.
212
+ // `image_format` forces this profile's provider content-part shape.
213
+ if (typeof entry.vision === 'boolean') normalized.vision = entry.vision;
214
+ if (entry.image_format === 'anthropic' || entry.image_format === 'openai') {
215
+ normalized.image_format = entry.image_format;
216
+ }
82
217
  return normalized;
83
218
  })
84
219
  : [];
85
220
  return merged;
86
221
  }
87
222
 
88
- function loadConfig() {
223
+ // ---------------------------------------------------------------------------
224
+ // Layered configuration (Task 2.2)
225
+ // ---------------------------------------------------------------------------
226
+ //
227
+ // Precedence, lowest to highest:
228
+ // 1. user config ~/.semalt-ai/config.json
229
+ // 2. project config .semalt/config.json (nearest, found by walking up to the
230
+ // repo root from the current working directory)
231
+ // 3. environment SEMALT_API_BASE, SEMALT_MODEL, HTTPS_PROXY/HTTP_PROXY
232
+ // 4. CLI flags --api-base, --api-key, --dashboard-url, --default-model
233
+ //
234
+ // The merge itself is a pure function (mergeConfigLayers) so each combination is
235
+ // unit-testable. API-key sourcing is intentionally NOT part of this merge — it
236
+ // stays in lib/secrets.js (SEMALT_API_KEY env → OS keychain → config.api_key),
237
+ // preserving the Phase 0 precedence.
238
+
239
+ // Raw read of the user config file (or null). Ensures the config dir exists so
240
+ // first-run save paths keep working.
241
+ function readUserConfig() {
89
242
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
90
- let cfg;
91
- if (fs.existsSync(CONFIG_PATH)) {
92
- try {
93
- const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
94
- cfg = normalizeConfig(data);
95
- } catch {}
243
+ if (!fs.existsSync(CONFIG_PATH)) return null;
244
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch { return null; }
245
+ }
246
+
247
+ // The user-file layer ONLY (no project/env/flags overlay). Persistence is based
248
+ // on this so transient overrides are never written back to config.json.
249
+ function loadUserConfig() {
250
+ return normalizeConfig(readUserConfig() || {});
251
+ }
252
+
253
+ // Walk up from startDir looking for .semalt/config.json. Bounded by the repo
254
+ // root: the directory holding .git is the last one checked. Returns the path of
255
+ // the nearest project config, or null.
256
+ function findProjectConfigPath(startDir) {
257
+ let dir = startDir;
258
+ while (true) {
259
+ const candidate = path.join(dir, '.semalt', 'config.json');
260
+ try { if (fs.existsSync(candidate)) return candidate; } catch {}
261
+ let atRepoRoot = false;
262
+ try { atRepoRoot = fs.existsSync(path.join(dir, '.git')); } catch {}
263
+ if (atRepoRoot) break;
264
+ const parent = path.dirname(dir);
265
+ if (parent === dir) break; // filesystem root
266
+ dir = parent;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ function loadProjectConfig(startDir) {
272
+ const p = findProjectConfigPath(startDir);
273
+ if (!p) return null;
274
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
275
+ }
276
+
277
+ // Environment → partial config layer. Pure: takes the env object. SEMALT_API_KEY
278
+ // is deliberately absent (owned by secrets.js).
279
+ function envConfigLayer(env = process.env) {
280
+ const layer = {};
281
+ if (typeof env.SEMALT_API_BASE === 'string' && env.SEMALT_API_BASE.trim()) {
282
+ layer.api_base = env.SEMALT_API_BASE.trim();
283
+ }
284
+ if (typeof env.SEMALT_MODEL === 'string' && env.SEMALT_MODEL.trim()) {
285
+ layer.default_model = env.SEMALT_MODEL.trim();
286
+ }
287
+ const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
288
+ const httpProxy = env.HTTP_PROXY || env.http_proxy;
289
+ if (typeof httpsProxy === 'string' && httpsProxy.trim()) layer.https_proxy = httpsProxy.trim();
290
+ if (typeof httpProxy === 'string' && httpProxy.trim()) layer.http_proxy = httpProxy.trim();
291
+ return layer;
292
+ }
293
+
294
+ // CLI flags → partial config layer. Pure: takes argv (the args after the
295
+ // subcommand). Only config-bearing flags are mapped; everything else is ignored.
296
+ function flagsConfigLayer(argv = []) {
297
+ const layer = {};
298
+ const MAP = {
299
+ '--api-base': 'api_base',
300
+ '--api-key': 'api_key',
301
+ '--dashboard-url': 'dashboard_url',
302
+ '--default-model': 'default_model',
303
+ '--reasoning-effort': 'reasoning_effort', // Task 2.7
304
+ '--max-iterations': 'max_iterations', // Pre-Task 4.0a
305
+ };
306
+ const BOOL = {
307
+ '--prompt-caching': 'prompt_caching', // Task 2.7
308
+ };
309
+ for (let i = 0; i < argv.length; i++) {
310
+ if (BOOL[argv[i]]) { layer[BOOL[argv[i]]] = true; continue; }
311
+ const key = MAP[argv[i]];
312
+ if (!key) continue;
313
+ const val = argv[i + 1];
314
+ if (val === undefined || val.startsWith('-')) continue;
315
+ layer[key] = val;
316
+ i++;
317
+ }
318
+ return layer;
319
+ }
320
+
321
+ // Pure precedence engine: shallow-merge the given partial layers (lowest first),
322
+ // skipping nullish layers and undefined values, then normalize. Exported and
323
+ // unit-tested across every layer combination.
324
+ function mergeConfigLayers(layers) {
325
+ const merged = {};
326
+ for (const layer of layers || []) {
327
+ if (!layer || typeof layer !== 'object') continue;
328
+ for (const [k, v] of Object.entries(layer)) {
329
+ if (v === undefined) continue;
330
+ merged[k] = v;
331
+ }
332
+ }
333
+ return normalizeConfig(merged);
334
+ }
335
+
336
+ // Pure: given the merged config a caller wants to persist, the previous merged
337
+ // view, and the current user-file object, return the object to write to the user
338
+ // config file. Only keys the caller actually CHANGED (vs the previous merged
339
+ // view) are layered onto the user file — so env/project/flag overrides merely
340
+ // carried along in `next` are never baked into config.json.
341
+ function userLayerForPersist(next, prevMerged, userFile) {
342
+ const out = { ...(userFile || {}) };
343
+ const prev = prevMerged || {};
344
+ for (const k of Object.keys(next || {})) {
345
+ if (JSON.stringify(next[k]) !== JSON.stringify(prev[k])) out[k] = next[k];
346
+ }
347
+ return out;
348
+ }
349
+
350
+ // SECURITY (Pre-Task 5.0a): hooks/verify are EXECUTABLE surfaces, and a project
351
+ // .semalt/config.json is attacker-controllable in a cloned repo. The shallow
352
+ // merge above lets a project layer introduce a command-type hook or a
353
+ // verify.command that would then run with host privileges. Mirroring how
354
+ // permission rules are loaded (loadRuleLayers reads the layers SEPARATELY so the
355
+ // project layer can only narrow), we re-resolve hooks/verify from the RAW user
356
+ // and project layers and quarantine project-introduced executables — keeping
357
+ // project PROMPT hooks (text injection only, already untrusted). The warning
358
+ // fires once so the user knows a project executable was ignored.
359
+ let _quarantineWarned = false;
360
+ function applyExecutableQuarantine(cfg, userRaw, projectRaw) {
361
+ const hookLayers = loadHookLayers(userRaw && userRaw.hooks, projectRaw && projectRaw.hooks);
362
+ cfg.hooks = hookLayers.hooks;
363
+ const verifyLayers = loadVerifyLayers(userRaw && userRaw.verify, projectRaw && projectRaw.verify);
364
+ cfg.verify = verifyLayers.verify;
365
+ if (!_quarantineWarned && (hookLayers.quarantined.length || verifyLayers.quarantinedCommand)) {
366
+ _quarantineWarned = true;
367
+ for (const q of hookLayers.quarantined) {
368
+ // audit: allowed — pre-UI startup warning, fires once before the TUI initialises.
369
+ process.stderr.write(`⚠ Ignored project-layer ${q.event} command hook (executable hooks from .semalt/config.json are quarantined): ${q.command}\n`);
370
+ }
371
+ if (verifyLayers.quarantinedCommand) {
372
+ process.stderr.write(`⚠ Ignored project-layer verify.command (quarantined; executable verify from .semalt/config.json is not honored): ${verifyLayers.quarantinedCommand}\n`);
373
+ }
96
374
  }
97
- if (!cfg) cfg = normalizeConfig();
375
+ return cfg;
376
+ }
377
+
378
+ // The merged runtime config. `argv` defaults to the process args after the node
379
+ // binary + script; callers that already parsed args may pass the relevant slice.
380
+ function loadConfig(argv) {
381
+ const flagsArgv = Array.isArray(argv) ? argv : process.argv.slice(2);
382
+ const userRaw = readUserConfig();
383
+ const projectRaw = loadProjectConfig(process.cwd());
384
+ const cfg = mergeConfigLayers([
385
+ userRaw,
386
+ projectRaw,
387
+ envConfigLayer(process.env),
388
+ flagsConfigLayer(flagsArgv),
389
+ ]);
390
+ applyExecutableQuarantine(cfg, userRaw, projectRaw);
98
391
  _maybeWarnApiKeyAny(cfg);
99
392
  return cfg;
100
393
  }
@@ -105,7 +398,9 @@ function saveConfig(cfg) {
105
398
  }
106
399
 
107
400
  function configSet(key, value) {
108
- const cfg = loadConfig();
401
+ // Operate on the USER file only — never the merged view — so a `config set`
402
+ // never bakes a project/env/flag override into config.json.
403
+ const cfg = loadUserConfig();
109
404
  cfg[key] = value;
110
405
  saveConfig(cfg);
111
406
  return cfg;
@@ -129,9 +424,26 @@ function configShow(systemPromptOverride = null) {
129
424
  const cfg = loadConfig();
130
425
  const lines = ['Config:'];
131
426
  for (const [key, val] of Object.entries(cfg)) {
132
- const display = REDACTED_KEYS.has(key) && val ? '****' : JSON.stringify(val);
427
+ let display;
428
+ if (REDACTED_KEYS.has(key) && val) {
429
+ display = '****';
430
+ } else if (key === 'models' && Array.isArray(val)) {
431
+ // Per-profile api_key values are secrets too — redact them before display.
432
+ display = JSON.stringify(val.map((m) => (
433
+ m && typeof m === 'object' && 'api_key' in m ? { ...m, api_key: m.api_key ? '****' : m.api_key } : m
434
+ )));
435
+ } else {
436
+ display = JSON.stringify(val);
437
+ }
133
438
  lines.push(` ${key}: ${display}`);
134
439
  }
440
+ // Surface WHERE the active API key resolves from (env / keychain / config /
441
+ // none) without ever printing the key itself. Keys from env/keychain are not
442
+ // stored in config.json, so this is the only signal the user gets.
443
+ try {
444
+ const { apiKeySource } = require('./secrets');
445
+ lines.push(` api_key_source: ${apiKeySource(cfg)}`);
446
+ } catch {}
135
447
  if (systemPromptOverride) {
136
448
  lines.push(` system_prompt: [override from ${systemPromptOverride}]`);
137
449
  } else {
@@ -146,6 +458,16 @@ module.exports = {
146
458
  configShow,
147
459
  isNativeToolsActive,
148
460
  loadConfig,
461
+ loadUserConfig,
149
462
  normalizeConfig,
463
+ resolveMaxIterations,
150
464
  saveConfig,
465
+ // Layered-config building blocks (Task 2.2) — pure and individually testable.
466
+ mergeConfigLayers,
467
+ envConfigLayer,
468
+ flagsConfigLayer,
469
+ findProjectConfigPath,
470
+ loadProjectConfig,
471
+ readUserConfig,
472
+ userLayerForPersist,
151
473
  };