Skip to content

Commit 91d648f

Browse files
authored
Harden gateway setup and release 0.1.4
Harden model gateway setup, bounded gateway responses, command sandbox policy, and release metadata for 0.1.4.
1 parent cc0914b commit 91d648f

39 files changed

Lines changed: 1027 additions & 84 deletions

README.md

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
# Keiko
22

3-
Keiko is a local enterprise coding assistant for regulated engineering teams. It helps inspect a repository, chat with configured language models, generate reviewable unit tests, investigate bugs, run verification, and keep redacted evidence for human review.
3+
Keiko is a governed agentic workspace for knowledge work that learns from experience.
44

5-
Keiko is developer-controlled by design. It does not commit, push, open pull requests, merge code, or apply changes without an explicit local action. The manifest-producing surfaces emit redacted evidence for audit.
5+
The current npm release starts with local developer-assist workflows for regulated engineering teams. Keiko helps inspect a repository, chat with configured language models, generate reviewable unit tests, investigate bugs, run verification, and keep redacted evidence for human review.
6+
7+
Keiko is human-controlled by design. It does not commit, push, open pull requests, merge code, or apply changes without an explicit local action. The manifest-producing surfaces emit redacted evidence for audit.
8+
9+
## Vision
10+
11+
Keiko's long-term direction is a governed workspace where people can delegate knowledge work to learning agents without giving up control, oversight, or accountability.
12+
13+
- **Governed delegation:** agents start with a task, not standing rights.
14+
- **Harness-first control:** agent actions, tool calls, connector access, approvals, failures, and outcomes flow through one observable control layer.
15+
- **Keiko Twin:** a governed work representative that can build controlled memory about user preferences, project routines, accepted outcomes, and recurring corrections.
16+
- **Learning from experience:** Keiko should improve future tool selection, escalation, policy suggestions, and workflow quality from structured evidence and feedback.
17+
- **Enterprise boundaries:** learning can improve suggestions and routines, but it must never grant itself authority or bypass human and organizational policy.
18+
19+
Software engineering is the first use case because repositories, tests, reviews, and tool calls create hard evidence. The product direction is broader: a controlled agentic workspace for enterprise knowledge work.
620

721
## Requirements
822

@@ -45,8 +59,12 @@ If no model gateway is configured, the UI asks for:
4559

4660
- Base URL, for example `https://llm-gateway.example.com/v1`
4761
- API token
62+
- Optional API-key header, only when your gateway admin provides a custom header
63+
- Deployment names, only when the gateway cannot expose a reliable model list
64+
65+
Keiko calls the gateway model list endpoint, tests discovered chat models with a small chat-completions request, and stores only callable chat models in the local runtime configuration. LiteLLM-compatible gateways can also provide model metadata that lets Keiko filter non-chat models before testing. Credentials stay on the local machine and are not returned to the browser.
4866

49-
Keiko calls the gateway model list endpoint, tests discovered chat models with a small chat-completions request, and stores only callable chat models in the local runtime configuration. Credentials stay on the local machine and are not returned to the browser.
67+
For OpenAI-compatible gateways such as LiteLLM, usually leave deployment names empty. For Azure AI Foundry, paste the deployment names you want Keiko to offer in the UI.
5068

5169
The UI runs on loopback only. The `--host` option can validate a loopback host value; the server always binds `127.0.0.1`.
5270

@@ -89,22 +107,27 @@ The UI can create a local runtime config during first-run setup. For scripted us
89107
{
90108
"modelId": "example-chat-model",
91109
"baseUrl": "https://llm-gateway.example.com/v1",
92-
"apiKey": "replace-me"
110+
"apiKey": "replace-me",
111+
"apiKeyHeaderName": "authorization"
93112
}
94113
]
95114
}
96115
```
97116

98117
Environment variables can override file values:
99118

100-
| Variable | Purpose |
101-
| --------------------------- | ------------------------------ |
102-
| `KEIKO_CONFIG_FILE` | Path to a gateway config file. |
103-
| `KEIKO_DEFAULT_BASE_URL` | Fallback gateway base URL. |
104-
| `KEIKO_DEFAULT_API_KEY` | Fallback gateway API token. |
105-
| `KEIKO_MODEL_<ID>_BASE_URL` | Per-model base URL override. |
106-
| `KEIKO_MODEL_<ID>_API_KEY` | Per-model API token override. |
107-
| `KEIKO_UI_PORT` | Local UI port override. |
119+
| Variable | Purpose |
120+
| -------------------------------------- | --------------------------------- |
121+
| `KEIKO_CONFIG_FILE` | Path to a gateway config file. |
122+
| `KEIKO_DEFAULT_BASE_URL` | Fallback gateway base URL. |
123+
| `KEIKO_DEFAULT_API_KEY` | Fallback gateway API token. |
124+
| `KEIKO_DEFAULT_API_KEY_HEADER_NAME` | Fallback credential header name. |
125+
| `KEIKO_MODEL_<ID>_BASE_URL` | Per-model base URL override. |
126+
| `KEIKO_MODEL_<ID>_API_KEY` | Per-model API token override. |
127+
| `KEIKO_MODEL_<ID>_API_KEY_HEADER_NAME` | Per-model credential header name. |
128+
| `KEIKO_UI_PORT` | Local UI port override. |
129+
130+
Supported credential headers are `authorization`, `x-litellm-key`, `x-api-key`, and `api-key`.
108131

109132
Do not commit gateway config files, API tokens, `.keiko/`, or evidence that contains project-specific review material unless your process explicitly requires it.
110133

@@ -129,13 +152,14 @@ Known limits:
129152

130153
## Troubleshooting
131154

132-
| Symptom | Check |
133-
| --------------------- | ------------------------------------------------------------------------------------------------------- |
134-
| UI does not open | Run `npm run keiko:status`, then inspect `.keiko/ui.log`. |
135-
| Port is busy | Start with `KEIKO_UI_PORT=1984 npm run keiko:start` or stop the process using the port. |
136-
| No model appears | Reopen Settings, verify the base URL and token, then run the credential test again. |
137-
| Credential test fails | Confirm the gateway accepts OpenAI-compatible chat-completions requests at the configured base URL. |
138-
| Stale process state | Run `npm run keiko:stop`, delete `.keiko/ui.pid` if the process is no longer running, then start again. |
155+
| Symptom | Check |
156+
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
157+
| UI does not open | Run `npm run keiko:status`, then inspect `.keiko/ui.log`. |
158+
| Port is busy | Start with `KEIKO_UI_PORT=1984 npm run keiko:start` or stop the process using the port. |
159+
| No model appears | Reopen Settings, verify the base URL and token, then run the credential test again. |
160+
| Credential test fails | Confirm the gateway accepts OpenAI-compatible chat-completions requests at the configured base URL. |
161+
| Custom proxy key fails | Confirm whether your gateway expects `Authorization` or a custom API-key header such as `X-Litellm-Key`. |
162+
| Stale process state | Run `npm run keiko:stop`, delete `.keiko/ui.pid` if the process is no longer running, then start again. |
139163

140164
## Further Reading
141165

docs/ui-runbook.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ When Keiko has no model gateway configuration, Settings asks for:
3333

3434
- Base URL, for example `https://llm-gateway.example.com/v1`
3535
- API token
36+
- Optional API-key header, only when your gateway admin provides a custom header
37+
- Deployment names, only when the gateway cannot expose a reliable model list
3638

37-
Keiko tests the credentials immediately. It lists available models from the gateway, performs a small chat-completions smoke test, and saves only callable chat models in the local runtime configuration.
39+
Supported credential headers are `authorization`, `x-litellm-key`, `x-api-key`, and `api-key`.
40+
41+
Keiko tests the credentials immediately. It lists available models from the gateway, uses LiteLLM-style model metadata when available, performs a small chat-completions smoke test, and saves only callable chat models in the local runtime configuration.
42+
43+
For OpenAI-compatible gateways such as LiteLLM, usually leave deployment names empty. For Azure AI Foundry, paste the deployment names you want Keiko to offer in the UI.
3844

3945
Environment variables are still supported for scripted starts. They are not a standalone UI configuration source when the UI needs to discover models for a first-time user.
4046

@@ -77,4 +83,5 @@ Keep `.keiko/`, runtime config files, and API tokens out of version control.
7783
| Port conflict | Stop the conflicting process or start Keiko with another port. |
7884
| No model appears | Re-run Settings credential test and confirm the gateway exposes chat models. |
7985
| Credential test fails | Confirm the base URL points to an OpenAI-compatible API and that the token is valid. |
86+
| Custom proxy key fails | Confirm whether your gateway expects `Authorization` or a custom API-key header. |
8087
| Stop does not clean up | Ensure the process is gone, then remove `.keiko/ui.pid` and start again. |

package-lock.json

Lines changed: 3 additions & 3 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": "@oscharko-dev/keiko",
3-
"version": "0.1.3",
3+
"version": "0.1.4",
44
"description": "Enterprise model-agnostic developer-assist coding agent for regulated engineering workflows.",
55
"type": "module",
66
"license": "Apache-2.0",

src/cli/lifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ async function cmdStart(
309309
return 1;
310310
}
311311
child.unref();
312-
writeFileSync(pidFile(options), `${String(child.pid)}\n`, "utf8");
312+
writeFileSync(pidFile(options), `${String(child.pid)}\n`, { encoding: "utf8", mode: 0o600 });
313313
io.out(`Starting Keiko UI on ${healthUrl(options).replace("/api/health", "")} ...\n`);
314314

315315
const healthy = await waitForHealth(options, child.pid, deps);

src/gateway/config.ts

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// and are excluded from every serialisation path.
55

66
import { readFileSync } from "node:fs";
7+
import { isIP } from "node:net";
78
import { ConfigInvalidError } from "./errors.js";
89
import type {
910
CircuitBreakerConfig,
@@ -21,11 +22,26 @@ const DEFAULT_RETRY_BASE_DELAY_MS = 500;
2122
const DEFAULT_FAILURE_THRESHOLD = 5;
2223
const DEFAULT_COOLDOWN_MS = 30_000;
2324
const DEFAULT_HALF_OPEN_PROBES = 2;
25+
export const DEFAULT_API_KEY_HEADER_NAME = "authorization";
26+
const MAX_API_KEY_HEADER_NAME_LENGTH = 64;
27+
const API_KEY_HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
28+
export const SUPPORTED_API_KEY_HEADER_NAMES = [
29+
DEFAULT_API_KEY_HEADER_NAME,
30+
"x-litellm-key",
31+
"x-api-key",
32+
"api-key",
33+
] as const;
34+
const SUPPORTED_API_KEY_HEADER_NAME_SET = new Set<string>(SUPPORTED_API_KEY_HEADER_NAMES);
35+
const BEARER_API_KEY_HEADER_NAME_SET = new Set<string>([
36+
DEFAULT_API_KEY_HEADER_NAME,
37+
"x-litellm-key",
38+
]);
2439

2540
export type EnvSource = Readonly<Record<string, string | undefined>>;
2641

2742
export interface SafeProviderConfig {
2843
readonly modelId: string;
44+
readonly credentialHeaderName: string;
2945
readonly timeoutMs: number;
3046
readonly maxRetries: number;
3147
readonly retryBaseDelayMs: number;
@@ -96,6 +112,45 @@ function optionalNonEmptyString(value: unknown, path: string, fallback: string):
96112
return requireNonEmptyString(value, path);
97113
}
98114

115+
export function normalizeApiKeyHeaderName(
116+
value: unknown,
117+
path: string,
118+
fallback = DEFAULT_API_KEY_HEADER_NAME,
119+
): string {
120+
if (value === undefined) {
121+
return fallback;
122+
}
123+
if (typeof value !== "string") {
124+
throw new ConfigInvalidError(`${path} must be a string`);
125+
}
126+
const headerName = value.trim().toLowerCase();
127+
if (headerName.length === 0) {
128+
return fallback;
129+
}
130+
if (
131+
headerName.length > MAX_API_KEY_HEADER_NAME_LENGTH ||
132+
!API_KEY_HEADER_NAME_RE.test(headerName)
133+
) {
134+
throw new ConfigInvalidError(`${path} must be a valid HTTP header name`);
135+
}
136+
if (!SUPPORTED_API_KEY_HEADER_NAME_SET.has(headerName)) {
137+
throw new ConfigInvalidError(
138+
`${path} must be one of ${SUPPORTED_API_KEY_HEADER_NAMES.join(", ")}`,
139+
);
140+
}
141+
return headerName;
142+
}
143+
144+
export function apiKeyHeaderValue(headerName: string, apiKey: string): string {
145+
if (
146+
BEARER_API_KEY_HEADER_NAME_SET.has(headerName) &&
147+
!apiKey.toLowerCase().startsWith("bearer ")
148+
) {
149+
return `Bearer ${apiKey}`;
150+
}
151+
return apiKey;
152+
}
153+
99154
function requireEnum<T extends string>(value: unknown, path: string, allowed: readonly T[]): T {
100155
if (typeof value !== "string" || !allowed.includes(value as T)) {
101156
throw new ConfigInvalidError(`${path} must be one of ${allowed.join(", ")}`);
@@ -120,20 +175,42 @@ function resolveSecret(modelId: string, fileValue: string, env: EnvSource, suffi
120175
return fallback ?? "";
121176
}
122177

178+
function resolveApiKeyHeaderName(
179+
rawValue: unknown,
180+
path: string,
181+
modelId: string,
182+
env: EnvSource,
183+
): string {
184+
const token = envModelToken(modelId);
185+
const perModelName = `KEIKO_MODEL_${token}_API_KEY_HEADER_NAME`;
186+
const perModel = env[perModelName];
187+
if (perModel !== undefined && perModel.length > 0) {
188+
return normalizeApiKeyHeaderName(perModel, perModelName);
189+
}
190+
if (rawValue !== undefined) {
191+
return normalizeApiKeyHeaderName(rawValue, path);
192+
}
193+
return normalizeApiKeyHeaderName(
194+
env.KEIKO_DEFAULT_API_KEY_HEADER_NAME,
195+
"KEIKO_DEFAULT_API_KEY_HEADER_NAME",
196+
);
197+
}
198+
123199
// Validates a resolved baseUrl for scheme and credential hygiene. Host/IP is
124200
// intentionally NOT restricted: Keiko addresses private network endpoints
125201
// (private IPs are a valid, first-class target); this guard is scheme/credential
126202
// hygiene + defence-in-depth, not host filtering.
127203
function isLoopbackHost(hostname: string): boolean {
128-
return (
129-
hostname === "localhost" ||
130-
hostname === "::1" ||
131-
hostname === "[::1]" ||
132-
hostname.startsWith("127.")
133-
);
204+
if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") {
205+
return true;
206+
}
207+
// Real IPv4 loopback only. isIP === 4 guarantees a well-formed dotted-quad, so a "127." prefix
208+
// here is the 127.0.0.0/8 block — never a domain such as "127.evil.com" or "127.0.0.1.evil.com".
209+
// The WHATWG URL parser has already canonicalised IPv4 shorthand/hex into url.hostname.
210+
return isIP(hostname) === 4 && hostname.startsWith("127.");
134211
}
135212

136-
function validateBaseUrl(baseUrl: string, path: string): void {
213+
export function validateBaseUrl(baseUrl: string, path: string): void {
137214
let url: URL;
138215
try {
139216
url = new URL(baseUrl);
@@ -143,6 +220,9 @@ function validateBaseUrl(baseUrl: string, path: string): void {
143220
if (url.protocol !== "http:" && url.protocol !== "https:") {
144221
throw new ConfigInvalidError(`${path}.baseUrl must use the http or https scheme`);
145222
}
223+
if (url.search !== "" || url.hash !== "") {
224+
throw new ConfigInvalidError(`${path}.baseUrl must not contain a query string or fragment`);
225+
}
146226
if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
147227
throw new ConfigInvalidError(
148228
`${path}.baseUrl must use https unless it targets localhost or loopback`,
@@ -243,6 +323,12 @@ function parseProviderConfig(
243323
modelId,
244324
baseUrl,
245325
apiKey,
326+
apiKeyHeaderName: resolveApiKeyHeaderName(
327+
raw.apiKeyHeaderName,
328+
`${path}.apiKeyHeaderName`,
329+
modelId,
330+
env,
331+
),
246332
timeoutMs: requirePositiveInt(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS, `${path}.timeoutMs`),
247333
maxRetries: requireNonNegativeInt(raw.maxRetries ?? DEFAULT_MAX_RETRIES, `${path}.maxRetries`),
248334
retryBaseDelayMs: requirePositiveInt(
@@ -331,6 +417,7 @@ export function toSafeObject(config: GatewayConfig): SafeGatewayConfig {
331417
return {
332418
providers: config.providers.map((provider) => ({
333419
modelId: provider.modelId,
420+
credentialHeaderName: provider.apiKeyHeaderName ?? DEFAULT_API_KEY_HEADER_NAME,
334421
timeoutMs: provider.timeoutMs,
335422
maxRetries: provider.maxRetries,
336423
retryBaseDelayMs: provider.retryBaseDelayMs,

src/gateway/http.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { readFileSync } from "node:fs";
22
import { request as httpsRequest } from "node:https";
33
import { rootCertificates } from "node:tls";
44

5+
// Caps a single gateway response at 10 MB; real chat completions are far smaller.
6+
export const MAX_RESPONSE_BYTES = 10_000_000;
7+
58
export interface GatewayFetchOptions extends RequestInit {
69
readonly fetchImpl?: typeof fetch | undefined;
710
readonly useCaFallback?: boolean | undefined;
@@ -92,7 +95,14 @@ function fetchWithCaBundle(url: string, init: RequestInit): Promise<Response> {
9295
},
9396
(res) => {
9497
const chunks: Buffer[] = [];
98+
let total = 0;
9599
res.on("data", (chunk: Buffer) => {
100+
total += chunk.length;
101+
if (total > MAX_RESPONSE_BYTES) {
102+
req.destroy();
103+
reject(new Error("gateway response exceeded the size limit"));
104+
return;
105+
}
96106
chunks.push(chunk);
97107
});
98108
res.on("end", () => {
@@ -127,3 +137,28 @@ export async function gatewayFetch(
127137
throw error;
128138
}
129139
}
140+
141+
export async function readJsonCapped(
142+
response: Response,
143+
maxBytes: number = MAX_RESPONSE_BYTES,
144+
): Promise<unknown> {
145+
if (response.body === null) {
146+
return response.json();
147+
}
148+
const reader = response.body.getReader();
149+
const decoder = new TextDecoder();
150+
const parts: string[] = [];
151+
let total = 0;
152+
for (;;) {
153+
const { done, value } = await reader.read();
154+
if (done) break;
155+
total += value.byteLength;
156+
if (total > maxBytes) {
157+
await reader.cancel();
158+
throw new Error("response body exceeded the size limit");
159+
}
160+
parts.push(decoder.decode(value, { stream: true }));
161+
}
162+
parts.push(decoder.decode());
163+
return JSON.parse(parts.join("")) as unknown;
164+
}

src/gateway/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ export {
3535
} from "./capabilities.js";
3636

3737
export {
38+
apiKeyHeaderValue,
39+
DEFAULT_API_KEY_HEADER_NAME,
3840
loadConfigFromFile,
41+
normalizeApiKeyHeaderName,
3942
parseGatewayConfig,
4043
toSafeObject,
44+
validateBaseUrl,
4145
type EnvSource,
4246
type SafeGatewayConfig,
4347
type SafeProviderConfig,

0 commit comments

Comments
 (0)