Skip to content

Commit d8bf92f

Browse files
committed
fix: reject malformed nit config
1 parent 5390699 commit d8bf92f

5 files changed

Lines changed: 152 additions & 21 deletions

File tree

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: nit
33
description: Git for agent identity — one identity, any apps
44
metadata:
5-
version: 0.6.19
5+
version: 0.6.20
66
---
77

88
# nit — Git for Agent Identity

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@newtype-ai/nit",
3-
"version": "0.6.19",
3+
"version": "0.6.20",
44
"description": "Version control for agent cards",
55
"type": "module",
66
"bin": {

src/config.ts

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -281,15 +281,40 @@ export async function clearRuntime(nitDir: string): Promise<void> {
281281
function parseConfig(raw: string): NitConfig {
282282
const remotes: Record<string, NitRemoteConfig> = {};
283283
const rpc: Record<string, NitRpcConfig> = {};
284-
let currentSection: string | null = null;
284+
let currentSection: 'remote' | 'rpc' | 'nit' | 'skills' | 'runtime' | null = null;
285285
let currentRemote: string | null = null;
286286
let currentRpcChain: string | null = null;
287287
let currentNitSubsection: string | null = null;
288288
let skillsDir: string | undefined;
289289
const nitSkillFields: Partial<Record<'source' | 'url', string>> = {};
290290
const runtimeFields: Partial<Record<'provider' | 'model' | 'harness' | 'declared_at', string>> = {};
291+
const seenKeys = new Set<string>();
292+
const rpcSections = new Map<string, number>();
293+
let skillsSectionLine: number | null = null;
294+
let nitSkillSectionLine: number | null = null;
295+
let runtimeSectionLine: number | null = null;
296+
297+
const fail = (lineNo: number, message: string): never => {
298+
throw new Error(`Invalid .nit/config line ${lineNo}: ${message}`);
299+
};
300+
const validateAtLine = (lineNo: number, validate: () => void): void => {
301+
try {
302+
validate();
303+
} catch (error) {
304+
fail(lineNo, error instanceof Error ? error.message : String(error));
305+
}
306+
};
307+
const markKey = (lineNo: number, sectionKey: string, key: string): void => {
308+
const marker = `${sectionKey}.${key}`;
309+
if (seenKeys.has(marker)) {
310+
fail(lineNo, `duplicate key "${key}"`);
311+
}
312+
seenKeys.add(marker);
313+
};
291314

292-
for (const line of raw.split('\n')) {
315+
const lines = raw.split('\n');
316+
for (const [index, line] of lines.entries()) {
317+
const lineNo = index + 1;
293318
const trimmed = line.trim();
294319

295320
// Skip empty lines and comments
@@ -300,6 +325,7 @@ function parseConfig(raw: string): NitConfig {
300325
if (remoteMatch) {
301326
currentSection = 'remote';
302327
currentRemote = remoteMatch[1];
328+
validateAtLine(lineNo, () => validateRemoteName(currentRemote!));
303329
currentRpcChain = null;
304330
currentNitSubsection = null;
305331
if (!remotes[currentRemote]) {
@@ -313,6 +339,8 @@ function parseConfig(raw: string): NitConfig {
313339
if (rpcMatch) {
314340
currentSection = 'rpc';
315341
currentRpcChain = rpcMatch[1];
342+
validateAtLine(lineNo, () => validateRpcChainName(currentRpcChain!));
343+
rpcSections.set(currentRpcChain, lineNo);
316344
currentRemote = null;
317345
currentNitSubsection = null;
318346
continue;
@@ -321,8 +349,12 @@ function parseConfig(raw: string): NitConfig {
321349
// Section header: [nit "skill"]
322350
const nitMatch = trimmed.match(/^\[nit\s+"([^"]+)"\]$/);
323351
if (nitMatch) {
352+
if (nitMatch[1] !== 'skill') {
353+
fail(lineNo, `unknown nit subsection "${nitMatch[1]}"`);
354+
}
324355
currentSection = 'nit';
325356
currentNitSubsection = nitMatch[1];
357+
nitSkillSectionLine = lineNo;
326358
currentRemote = null;
327359
currentRpcChain = null;
328360
continue;
@@ -331,6 +363,7 @@ function parseConfig(raw: string): NitConfig {
331363
// Section header: [skills]
332364
if (trimmed === '[skills]') {
333365
currentSection = 'skills';
366+
skillsSectionLine = lineNo;
334367
currentRemote = null;
335368
currentRpcChain = null;
336369
currentNitSubsection = null;
@@ -340,39 +373,90 @@ function parseConfig(raw: string): NitConfig {
340373
// Section header: [runtime]
341374
if (trimmed === '[runtime]') {
342375
currentSection = 'runtime';
376+
runtimeSectionLine = lineNo;
343377
currentRemote = null;
344378
currentRpcChain = null;
345379
currentNitSubsection = null;
346380
continue;
347381
}
348382

383+
if (trimmed.startsWith('[')) {
384+
fail(lineNo, `unknown section "${trimmed}"`);
385+
}
386+
349387
// Key-value pair: key = value
350388
const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
351389
if (kvMatch) {
352390
const [, key, value] = kvMatch;
391+
const parsedValue = value.trim();
392+
if (!currentSection) {
393+
fail(lineNo, `key "${key}" appears before any section`);
394+
}
353395
if (currentSection === 'remote' && currentRemote !== null) {
396+
markKey(lineNo, `remote.${currentRemote}`, key);
354397
if (key === 'url') {
355-
remotes[currentRemote].url = value.trim();
398+
validateAtLine(lineNo, () => validateHttpUrl(parsedValue, `Remote URL for "${currentRemote}"`));
399+
remotes[currentRemote].url = parsedValue;
356400
} else if (key === 'credential') {
357-
remotes[currentRemote].credential = value.trim();
401+
validateAtLine(lineNo, () => validateConfigValue(parsedValue, `Remote credential for "${currentRemote}"`));
402+
remotes[currentRemote].credential = parsedValue;
403+
} else {
404+
fail(lineNo, `unknown remote key "${key}"`);
358405
}
359406
} else if (currentSection === 'rpc' && currentRpcChain !== null) {
407+
markKey(lineNo, `rpc.${currentRpcChain}`, key);
360408
if (key === 'url') {
361-
rpc[currentRpcChain] = { url: value.trim() };
409+
validateAtLine(lineNo, () => validateHttpUrl(parsedValue, `RPC URL for "${currentRpcChain}"`));
410+
rpc[currentRpcChain] = { url: parsedValue };
411+
} else {
412+
fail(lineNo, `unknown rpc key "${key}"`);
362413
}
363414
} else if (currentSection === 'skills') {
415+
markKey(lineNo, 'skills', key);
364416
if (key === 'dir') {
365-
skillsDir = value.trim();
417+
validateAtLine(lineNo, () => validateConfigValue(parsedValue, 'Skills directory'));
418+
skillsDir = parsedValue;
419+
} else {
420+
fail(lineNo, `unknown skills key "${key}"`);
366421
}
367422
} else if (currentSection === 'runtime') {
423+
markKey(lineNo, 'runtime', key);
368424
if (key === 'provider' || key === 'model' || key === 'harness' || key === 'declared_at') {
369-
runtimeFields[key] = value.trim();
425+
runtimeFields[key] = parsedValue;
426+
} else {
427+
fail(lineNo, `unknown runtime key "${key}"`);
370428
}
371429
} else if (currentSection === 'nit' && currentNitSubsection === 'skill') {
430+
markKey(lineNo, 'nit.skill', key);
372431
if (key === 'source' || key === 'url') {
373-
nitSkillFields[key] = value.trim();
432+
nitSkillFields[key] = parsedValue;
433+
} else {
434+
fail(lineNo, `unknown nit skill key "${key}"`);
374435
}
375436
}
437+
continue;
438+
}
439+
440+
fail(lineNo, `malformed line "${trimmed}"`);
441+
}
442+
443+
for (const [chain, lineNo] of rpcSections.entries()) {
444+
if (!rpc[chain]?.url) {
445+
fail(lineNo, `rpc "${chain}" requires a url`);
446+
}
447+
}
448+
if (skillsSectionLine !== null && !skillsDir) {
449+
fail(skillsSectionLine, 'skills section requires dir');
450+
}
451+
if (nitSkillSectionLine !== null && !nitSkillFields.source) {
452+
fail(nitSkillSectionLine, 'nit skill section requires source');
453+
}
454+
if (runtimeSectionLine !== null) {
455+
const requiredRuntimeKeys = ['provider', 'model', 'harness', 'declared_at'] as const;
456+
for (const key of requiredRuntimeKeys) {
457+
if (!runtimeFields[key]) {
458+
fail(runtimeSectionLine, `runtime section requires ${key}`);
459+
}
376460
}
377461
}
378462

@@ -381,15 +465,26 @@ function parseConfig(raw: string): NitConfig {
381465
config.rpc = rpc;
382466
}
383467
if (runtimeFields.provider && runtimeFields.model && runtimeFields.harness && runtimeFields.declared_at) {
384-
const declaredAt = parseInt(runtimeFields.declared_at, 10);
385-
if (Number.isFinite(declaredAt)) {
386-
config.runtime = {
387-
provider: runtimeFields.provider,
388-
model: runtimeFields.model,
389-
harness: runtimeFields.harness,
390-
declared_at: declaredAt,
391-
};
468+
const lineNo = runtimeSectionLine ?? 1;
469+
validateAtLine(lineNo, () => validateRuntimeField(runtimeFields.provider!, 'runtime.provider'));
470+
validateAtLine(lineNo, () => validateRuntimeField(runtimeFields.model!, 'runtime.model'));
471+
validateAtLine(lineNo, () => validateRuntimeField(runtimeFields.harness!, 'runtime.harness'));
472+
if (!PROVIDER_RE.test(runtimeFields.provider)) {
473+
fail(lineNo, 'runtime.provider must contain only lowercase letters, digits, and hyphens');
474+
}
475+
if (!/^\d+$/.test(runtimeFields.declared_at)) {
476+
fail(lineNo, 'runtime.declared_at must be an integer');
392477
}
478+
const declaredAt = Number(runtimeFields.declared_at);
479+
if (!Number.isSafeInteger(declaredAt)) {
480+
fail(lineNo, 'runtime.declared_at must be a safe integer');
481+
}
482+
config.runtime = {
483+
provider: runtimeFields.provider,
484+
model: runtimeFields.model,
485+
harness: runtimeFields.harness,
486+
declared_at: declaredAt,
487+
};
393488
}
394489
if (nitSkillFields.source) {
395490
const nitSkill: NitSkillConfig = {
@@ -398,7 +493,7 @@ function parseConfig(raw: string): NitConfig {
398493
if (nitSkillFields.url) {
399494
nitSkill.url = nitSkillFields.url;
400495
}
401-
validateNitSkillConfig(nitSkill);
496+
validateAtLine(nitSkillSectionLine ?? 1, () => validateNitSkillConfig(nitSkill));
402497
config.nitSkill = nitSkill;
403498
}
404499
return config;

tests/local-regression.test.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,42 @@ test('remote add rejects unsafe names before writing config', () => {
194194
assert.equal(readFileSync(configPath, 'utf8'), before);
195195
});
196196

197+
test('commands reject malformed config instead of ignoring it', () => {
198+
const cwd = workspace('nit-config-');
199+
initWorkspace(cwd);
200+
201+
const configPath = join(cwd, '.nit', 'config');
202+
const cases = [
203+
{
204+
config: '[remote "origin"]\n url = https://api.newtype-ai.org\n typo = ignored\n',
205+
pattern: /Invalid \.nit\/config line 3: unknown remote key "typo"/,
206+
},
207+
{
208+
config: '[remote "origin"]\n url = file:///tmp/socket\n',
209+
pattern: /Invalid \.nit\/config line 2:.*http:\/\/ or https:\/\//,
210+
},
211+
{
212+
config: '[rpc "evm"]\n',
213+
pattern: /Invalid \.nit\/config line 1: rpc "evm" requires a url/,
214+
},
215+
{
216+
config: '[runtime]\n provider = OpenAI\n model = gpt\n harness = codex\n declared_at = 123abc\n',
217+
pattern: /Invalid \.nit\/config line 1: runtime\.provider must contain only lowercase/,
218+
},
219+
{
220+
config: 'url = https://api.newtype-ai.org\n',
221+
pattern: /Invalid \.nit\/config line 1: key "url" appears before any section/,
222+
},
223+
];
224+
225+
for (const item of cases) {
226+
writeFileSync(configPath, item.config, 'utf8');
227+
const result = runNit(cwd, ['remote']);
228+
assert.notEqual(result.status, 0, item.config);
229+
assert.match(stripAnsi(result.stderr), item.pattern);
230+
}
231+
});
232+
197233
test('rpc set-url rejects unsafe chain names and non-http URLs', () => {
198234
const cwd = workspace('nit-rpc-');
199235
initWorkspace(cwd);

0 commit comments

Comments
 (0)