Skip to content

Commit fc6e6ef

Browse files
author
TinyCode
committed
hardening: key-hygiene guard — warn on secret-like fields in config.json, ignore .env*/keys
1 parent 4a29d6e commit fc6e6ef

5 files changed

Lines changed: 69 additions & 2 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,10 @@ dist/
33
.tmp/
44
*.log
55
.DS_Store
6+
7+
# secrets & local credentials — never commit
8+
.env
9+
.env.*
10+
*.pem
11+
*.key
12+
.tinycode/*.local.json

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ TinyCode's permission system is an **approval layer + workspace path guard, not
162162
- Shell commands pass a risk classifier plus the same approval flow; they are not confined —
163163
an approved `bash` call can do anything your user can.
164164
- Running genuinely untrusted code/tasks requires an external sandbox (container, VM).
165+
- **API keys live in environment variables only.** `.gitignore` already excludes
166+
`.env*`, `*.key`, `*.pem` and `.tinycode/*.local.json`; if a secret-looking field
167+
appears in `.tinycode/config.json` (which is meant to be committed), startup prints
168+
a loud warning.
165169

166170
## Documentation
167171

src/cli/commands.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ export async function buildHarnessFromCli(options: {
6464
mock: boolean;
6565
session?: { mode: "new" } | { mode: "attach"; id: string };
6666
}): Promise<Harness> {
67-
const { config } = loadConfig(options.cwd);
67+
const { config, warnings } = loadConfig(options.cwd);
68+
for (const warning of warnings) {
69+
process.stderr.write(`warning: ${warning}\n`);
70+
}
6871
const modelRef = options.modelFlag ? parseModelRef(options.modelFlag) : undefined;
6972
const harness = await bootstrapHarness({
7073
projectRoot: options.cwd,

src/config/loader.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ function parseModelRef(ref: string): { provider?: string; model?: string } {
2929
};
3030
}
3131

32+
/**
33+
* Defense against the classic accident: pasting an API key into config.json
34+
* (which is safe to commit) and pushing it. Unknown to the schema by design —
35+
* keys come from environment variables — so any secret-looking field is a mistake.
36+
*/
37+
const SECRET_FIELD_RE = /^(.*(?:api[_-]?key|apikey|secret|token|password|credential).*|sk-.*)$/i;
38+
39+
function findSecretLikeFields(value: unknown, prefix = ""): string[] {
40+
const hits: string[] = [];
41+
if (Array.isArray(value)) return hits;
42+
if (value !== null && typeof value === "object") {
43+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
44+
const field = prefix ? `${prefix}.${key}` : key;
45+
if (SECRET_FIELD_RE.test(key) || (typeof nested === "string" && /^sk-[A-Za-z0-9]/.test(nested))) {
46+
hits.push(field);
47+
} else {
48+
hits.push(...findSecretLikeFields(nested, field));
49+
}
50+
}
51+
}
52+
return hits;
53+
}
54+
3255
export interface LoadedConfig {
3356
config: TinyCodeConfig;
3457
/** Non-fatal problems: unreadable file, schema violations of unknown shape. */
@@ -47,7 +70,15 @@ export function loadConfig(projectRoot: string): LoadedConfig {
4770
const file = path.join(projectRoot, ".tinycode", "config.json");
4871
try {
4972
const raw = readFileSync(file, "utf8");
50-
const parsed = configSchema.safeParse(JSON.parse(raw));
73+
const json: unknown = JSON.parse(raw);
74+
const secretFields = findSecretLikeFields(json);
75+
if (secretFields.length > 0) {
76+
warnings.push(
77+
`${file} contains field(s) ${secretFields.map((f) => `"${f}"`).join(", ")} that look like API keys. ` +
78+
`Keys are read from environment variables only; this file may be committed — remove secrets from it.`,
79+
);
80+
}
81+
const parsed = configSchema.safeParse(json);
5182
if (parsed.success) {
5283
config = parsed.data;
5384
} else {

tests/config.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,28 @@ describe("config loader", () => {
6060
expect(config.permissionMode).toBe("auto");
6161
});
6262

63+
it("warns when config.json contains secret-looking fields", () => {
64+
fs.mkdirSync(path.join(root, ".tinycode"));
65+
fs.writeFileSync(
66+
path.join(root, ".tinycode", "config.json"),
67+
JSON.stringify({ provider: "openrouter", apiKey: "sk-or-v1-abc123", mcpServers: { s: { command: "x" } } }),
68+
);
69+
const { warnings } = loadConfig(root);
70+
expect(warnings.join(" ")).toMatch(/look like API keys/);
71+
expect(warnings.join(" ")).toContain('"apiKey"');
72+
// The file itself must stay git-ignored-safe: schema still parses the rest.
73+
});
74+
75+
it("warns on nested sk-prefixed values even with innocuous field names", () => {
76+
fs.mkdirSync(path.join(root, ".tinycode"));
77+
fs.writeFileSync(
78+
path.join(root, ".tinycode", "config.json"),
79+
JSON.stringify({ provider: "openrouter", note: "sk-v1-hidden-in-text" }),
80+
);
81+
const { warnings } = loadConfig(root);
82+
expect(warnings.join(" ")).toMatch(/look like API keys/);
83+
});
84+
6385
it("ignores TINYCODE_MODEL=mock (handled by the registry)", () => {
6486
process.env.TINYCODE_MODEL = "mock";
6587
const { config } = loadConfig(root);

0 commit comments

Comments
 (0)