Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ A Model Context Protocol (MCP) server that exposes the Kosli API to LLM clients
- ESM only (`"type": "module"`, `NodeNext` resolution). Relative imports must use `.js` extensions even though the source is `.ts`.
- TypeScript `strict: true`. Don't weaken it.
- The `org` path parameter falls back to `config.org` (from `KOSLI_ORG`). Preserve this in `KosliClient.buildUrl`.
- A per-call org is interpreted in exactly one place: `normalizeOrg` + `orgError` in `src/tools/execute-action.ts`, which both execute tools go through (the `KOSLI_ORG` default never passes through them: `loadConfig` trims it and rejects a blank one at startup, and `KosliClient.buildUrl` falls back to it when a call names no org). The `org` tool input and `params.org` obey the same rules — trimmed, only a string names one (coercing would turn `["a", "b"]` into the org `a,b`), `null` means not supplied, blank rejected, an org on a non-org-scoped action rejected, and two different orgs in one call rejected rather than resolved, because the same path performs writes. Note the ordering: `params.org` is read *before* `unwrapBodyParam` and the body's org *after* it — the unwrap flattens a write's body over the top level, so reading only once lets the spread pick a winner and hides the disagreement. Keep that decision in the one place; don't re-implement it per tool in `index.ts`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content is right and worth recording — the ordering constraint around unwrapBodyParam is exactly the kind of thing that gets silently undone by a refactor, so pinning it here is the correct move.

Form is off for the file, though. Every other bullet in Conventions is one or two lines; this one is a ~12-line paragraph carrying six separate rules, and the ordering constraint — the load-bearing part — is the fifth sentence in. CLAUDE.md is read by agents that skim for the rule they need, so burying it costs the most where it matters most.

Same content, split so each rule is findable:

Suggested change
- A per-call org is interpreted in exactly one place: `normalizeOrg` + `orgError` in `src/tools/execute-action.ts`, which both execute tools go through (the `KOSLI_ORG` default never passes through them: `loadConfig` trims it and rejects a blank one at startup, and `KosliClient.buildUrl` falls back to it when a call names no org). The `org` tool input and `params.org` obey the same rules — trimmed, only a string names one (coercing would turn `["a", "b"]` into the org `a,b`), `null` means not supplied, blank rejected, an org on a non-org-scoped action rejected, and two different orgs in one call rejected rather than resolved, because the same path performs writes. Note the ordering: `params.org` is read *before* `unwrapBodyParam` and the body's org *after* it — the unwrap flattens a write's body over the top level, so reading only once lets the spread pick a winner and hides the disagreement. Keep that decision in the one place; don't re-implement it per tool in `index.ts`.
- A per-call org is interpreted in exactly one place: `normalizeOrg` + `orgError` in `src/tools/execute-action.ts`, which both execute tools go through. Don't re-implement it per tool in `index.ts`. The rules, applied identically to the `org` tool input and to `params.org`: trimmed; only a string names an org (coercing would turn `["a", "b"]` into the org `a,b`); `null` means not supplied; blank rejected; an org on a non-org-scoped action rejected; two different orgs in one call rejected rather than resolved, because the same path performs writes.
- **Ordering matters in `executeAction`:** `params.org` is read *before* `unwrapBodyParam` and the body's org *after* it. The unwrap flattens a write's body over the top level, so reading only once lets the spread pick a winner and hides the disagreement.
- The `KOSLI_ORG` default never passes through `normalizeOrg`: `loadConfig` trims it and rejects a blank one at startup, and `KosliClient.buildUrl` falls back to it when a call names no org.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining for this PR. Splitting the bullet is a readability change to a file this PR already rewrote twice, and it carries no behaviour.

The ordering constraint is the part worth finding, so I take the point about where it sits. Raising it with the rest of the CLAUDE.md tidy rather than adding a fifth commit here.

no mutation: this reply adds no test claim.

- Errors from the Kosli API are returned as `{ error: true, status, statusText, message }` — not thrown. Tools stringify whatever they get. Keep this contract; the LLM handles the error object.
- Responses are serialized with `JSON.stringify(result)` (compact, no pretty-printing) to minimize token usage. Don't revert to pretty-printing.
- All API requests include `User-Agent: kosli-mcp-server/<version>` for server-side tracking, with the version coming from `VERSION` in `src/version.ts`. Preserve the header and keep the version in it — the backend uses it to tell releases apart.
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The server reads configuration from environment variables:
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `KOSLI_API_TOKEN` | yes | — | Preferred. `KOSLI_API_KEY` is accepted as a fallback. |
| `KOSLI_ORG` | yes | — | Default org used when a path param `org` is not supplied. |
| `KOSLI_ORG` | yes | — | Default org used when a call does not supply one. Trimmed; whitespace-only is rejected at startup. |
| `KOSLI_BASE_URL` | no | `https://app.kosli.com` | EU (default), US (`https://app.us.kosli.com`), or your single-tenant endpoint. |

## Wire up to an MCP client
Expand Down Expand Up @@ -107,7 +107,38 @@ Typical LLM flow:
> [!IMPORTANT]
> `execute_write_action` creates, modifies, and deletes real resources in your Kosli organization. MCP clients gate these calls behind an approval prompt — read the action ID and parameters before approving. An LLM may select the wrong action, or the right action with the wrong parameters, and approval is the only checkpoint before the call is made. Treat deletions and anything touching service accounts or API keys with particular care.

The `org` path parameter defaults to `KOSLI_ORG` if not supplied. For `GET`/`DELETE`, non-path params become query parameters; for other methods they become the JSON body.
For `GET`/`DELETE`, non-path params become query parameters; for other methods they become the JSON body.

### Working with more than one organization

Both execute tools take an optional `org`, so a single running server can reach
any organization your token has access to — useful when you are moving between
orgs in one conversation rather than restarting the server for each:

```json
{ "actionId": "list_envs", "org": "cyber-dojo", "fields": ["name"] }
```

- Omit it and the call goes to `KOSLI_ORG`.
- It applies to that one call. The next call goes back to `KOSLI_ORG`.
- Surrounding whitespace is trimmed. A blank org is rejected rather than
quietly producing a URL with the organization missing from it.
- One org, named as a string. Anything else is rejected rather than coerced,
because coercion turns a value that is not a name into one that looks like a
name: `["cyber-dojo", "kosli-public"]` would request the org
`cyber-dojo,kosli-public`, and an org id would request an org called `1234`.
- Four actions are not organization-scoped (`get_user_default_org`,
`list_system_attestation_types`, and the two `/schemas/...` actions). Passing
an org to one of those is rejected rather than ignored, whether it arrives as
`org` or inside `params`.
- There are three ways to name an org in one call: this input, `params.org`, and
an `org` inside a write's request body. They must agree; two different names
are rejected rather than resolved, because the same tools perform writes and
the target is never guessed.
- An org your token cannot reach comes back as the usual error object carrying
the API's status — `403` for both an org you are not a member of and one that
does not exist. Note that Kosli staff accounts can read any org, so a
successful read does not prove a write would be permitted.

Both execute tools accept an optional `fields` array to request only specific top-level fields from each object in the response. This dramatically reduces response size and token usage:

Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ export function loadConfig(): Config {
throw new Error("KOSLI_API_TOKEN (or KOSLI_API_KEY) environment variable is required");
}

const org = process.env.KOSLI_ORG;
// Trimmed so the existing check also catches a whitespace-only value, which
// is truthy and would otherwise become the org path segment on every call.
const org = process.env.KOSLI_ORG?.trim();
if (!org) {
throw new Error("KOSLI_ORG environment variable is required");
throw new Error("KOSLI_ORG environment variable is required, and must not be blank");
}

const baseUrl = process.env.KOSLI_BASE_URL || "https://app.kosli.com";
Expand Down
19 changes: 13 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { VERSION } from "./version.js";
const entries = catalog as CatalogEntry[];
const config = loadConfig();

// Shared by both execute tools so the two can't drift apart.
const ORG_INPUT = z.string().optional().describe(
`Kosli organization to run this call against, e.g. "cyber-dojo". Defaults to "${config.org}" (from KOSLI_ORG). It applies to this call only — the next call goes back to the default unless you set it again.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one thing that stops the PR's headline benefit from landing in practice, and it's outside the files you touched.

The stated win is that a write's target org shows up in the approval prompt because it is a top-level input. That only happens if the model actually fills this input. But search_actions hands back entry.parameters verbatim (src/tools/search-actions.ts:50), and for all but four catalog actions that array contains:

{ "name": "org", "in": "path", "required": true, "description": "Organization name" }

So the documented flow — search first, then execute with the parameters you were given — tells the model that org is a required path parameter, i.e. something to put in params. This description says the opposite, and the two are read at different moments: the search result arrives with the action id the model is about to use, this description arrives with the schema. When they disagree, required: true in a per-action payload is the stronger signal.

Both channels work, so nothing breaks. But the case the PR set out to fix — org visible in the write-approval prompt rather than buried in a params blob — is exactly the case that reverts to the old shape whenever the model follows the search output.

Cheapest fix is a sentence here that names the competing signal, so the model doesn't have to resolve it by guessing:

Suggested change
`Kosli organization to run this call against, e.g. "cyber-dojo". Defaults to "${config.org}" (from KOSLI_ORG). It applies to this call only — the next call goes back to the default unless you set it again.`,
`Kosli organization to run this call against, e.g. "cyber-dojo". Defaults to "${config.org}" (from KOSLI_ORG). It applies to this call only — the next call goes back to the default unless you set it again. search_actions lists 'org' among an action's required path parameters; set it here rather than in params, and omit it entirely to use the default.`,

The more thorough version is to have searchActions drop the org path param from the parameters it returns (or annotate it as supplied by the server) — the model never needs to fill it, and it costs tokens on every search hit. That's a separate change to a file this PR doesn't touch, so a follow-up rather than something to fold in here.

Fix this →

);

const server = new McpServer({
name: "kosli",
version: VERSION,
Expand Down Expand Up @@ -48,18 +53,19 @@ server.registerTool(
"execute_read_action",
{
title: "Execute read action",
description: `Execute a read-only (GET) Kosli API action by its ID with the given parameters. Use search_actions first to find the action ID and required parameters. The 'org' parameter defaults to "${config.org}" — you do not need to supply it unless querying a different organization.`,
description: `Execute a read-only (GET) Kosli API action by its ID with the given parameters. Use search_actions first to find the action ID and required parameters. The call runs against the "${config.org}" organization unless you set 'org'.`,
annotations: {
readOnlyHint: true,
},
inputSchema: {
actionId: z.string().describe("The action ID from search_actions results"),
org: ORG_INPUT,
params: z.record(z.string(), z.unknown()).optional().default({}).describe("Parameters for the action (path params or query params)"),
fields: z.array(z.string()).optional().describe("Only include these fields in each object of the response. Dramatically reduces response size. Example: [\"name\",\"compliant\",\"fingerprint\",\"reasons_for_incompliance\"]"),
},
},
async ({ actionId, params, fields }) => {
const result = await executeAction(entries, config, actionId, params, fields, undefined, "GET");
async ({ actionId, params, fields, org }) => {
const result = await executeAction(entries, config, actionId, params, fields, undefined, "GET", org);
return {
content: [
{
Expand All @@ -75,19 +81,20 @@ server.registerTool(
"execute_write_action",
{
title: "Execute write action",
description: `Execute a write (POST, PUT, PATCH, DELETE) Kosli API action by its ID with the given parameters. Use search_actions first to find the action ID and required parameters. The 'org' parameter defaults to "${config.org}" — you do not need to supply it unless querying a different organization.`,
description: `Execute a write (POST, PUT, PATCH, DELETE) Kosli API action by its ID with the given parameters. Use search_actions first to find the action ID and required parameters. The call runs against the "${config.org}" organization unless you set 'org' — check it before approving, the write lands in whichever org it names.`,
annotations: {
destructiveHint: true,
readOnlyHint: false,
},
inputSchema: {
actionId: z.string().describe("The action ID from search_actions results"),
org: ORG_INPUT,
params: z.record(z.string(), z.unknown()).optional().default({}).describe("Parameters for the action (path params, query params, or body)"),
fields: z.array(z.string()).optional().describe("Only include these fields in each object of the response. Dramatically reduces response size."),
},
},
async ({ actionId, params, fields }) => {
const result = await executeAction(entries, config, actionId, params, fields, undefined, "WRITE");
async ({ actionId, params, fields, org }) => {
const result = await executeAction(entries, config, actionId, params, fields, undefined, "WRITE", org);
return {
content: [
{
Expand Down
81 changes: 80 additions & 1 deletion src/tools/execute-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,60 @@ function unwrapBodyParam(
return { ...rest, ...(body as Record<string, unknown>) };
}

/**
* The org a call is aimed at, as it will actually be used: trimmed, and
* `undefined` when nothing was supplied. `null` counts as nothing, matching
* `KosliClient.buildUrl`, which falls back to `config.org` on a nullish
* `params.org`. An empty string means "supplied, but unusable", which
* `orgError` turns into a rejection.
*/
function normalizeOrg(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
// Only a string can name an org. `params` is a record of `unknown`, so the
// model can put anything here, and coercing it would turn a value that is not
// a name into one that looks like a name: `["a", "b"]` reads as the org "a,b"
// and an org id reads as an org called "1234". Both would then pass the
// agreement check below as a single name. Hand back the unusable marker
// instead and let orgError say so.
if (typeof value !== "string") return "";
return value.trim();
}
Comment on lines +66 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String(value) on an unknown is the one hole left in "two different orgs in one call are rejected".

org as a tool input is z.string(), but params is z.record(z.string(), z.unknown()), so params.org — and a body org after the unwrap — can be any JSON the model emits. An array collapses to a single string before the dedupe ever sees it:

params: { org: ["cyber-dojo", "kosli-public"] }
  → normalizeOrg → "cyber-dojo,kosli-public"
  → named = ["cyber-dojo,kosli-public"]   // length 1, no conflict
  → GET /api/v2/environments/cyber-dojo%2Ckosli-public → 403

That's exactly the shape a model reaches for when asked to list envs across two orgs, and it gets a 403 on a nonsense org rather than the "supply just one" message the PR added for it. {} behaves the same way via "[object Object]".

The outcome is safe — no write lands in an unintended org, because no org has a comma in its name — so this is a diagnostics gap, not a correctness one. But normalizeOrg is the function whose stated contract is that the target is never guessed, and stringifying a container is a guess. Cheapest fix reuses the rejection path already there:

Suggested change
function normalizeOrg(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
return String(value).trim();
}
function normalizeOrg(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
// Only a primitive can name an org. An array or object would stringify into
// a single plausible-looking value ("a,b", "[object Object]") and pass the
// conflict check below as one org, so collapse it and let orgError reject it.
if (typeof value === "object") return "";
return String(value).trim();
}

Worth widening the blank-org message slightly if you take it — "An empty org was given" reads oddly for ["a","b"]. Something like Name a single organization as a string, or omit the org parameter to use the configured default. covers both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, and widened. Asking "is it an object" still coerces a number, and that case is worse. The org path parameter has no pattern in the catalog, so an org named 1234 is possible. A model holding an org id would write into a real organization nobody named. normalizeOrg now requires a string.

Two follow-ons, in e275976. The "" marker reached the disagreement branch, so a container next to a valid org reported a conflict against a nameless org. Both messages also said "the org parameter", which here means the tool input.

verified: sed -n 91,105p src/tools/execute-action.ts
function orgError(entry: CatalogEntry, named: string[]): string | undefined {
const takesOrg = entry.parameters.some((p) => p.name === "org" && p.in === "path");
if (!takesOrg) {
if (named.length === 0) return undefined;
return Action "${entry.id}" is not organization-scoped. It takes no org. Retry with no org in the org parameter, in params.org, or in the request body.;
}

// Before any disagreement: an unusable value is the thing to report, and
// reporting it as a nameless org disagreeing with a real one would send the
// caller to drop one of the two rather than to fix the bad value.
if (named.includes("")) {
  return "The org must be a single non-empty organization name. Check the org parameter, params.org, and any org in the request body, or omit all of them to use the configured default.";
}

if (named.length > 1) {

search: grep -rnE "String(|Number(|Boolean(" src/ --include=*.ts
client/kosli-client.ts:51,57 String(item) / String(value) (multipart form fields)
client/kosli-client.ts:109 String(v) (query values)
client/kosli-client.ts:146 encodeURIComponent(String(value)) (path params other than org)

Same coercion, other params. No other path param picks the organization, so a wrong one fails rather than succeeding somewhere unintended. Left alone.

mutation: normalizeOrg returning String(value).trim() -> five "rejects ... rather than coercing it into the path" cases red; moving the unusable check after the conflict check -> "reports the unusable value, not a disagreement" red


/**
* Check the orgs a call names, returning a message when the request cannot be
* honoured as written. This is the only place the target org is decided, so
* both execute tools behave identically and every way of naming one obeys the
* same rules.
*
* `named` holds the distinct orgs the call mentions, already normalized. There
* are three ways to name one and they must agree: the `org` argument,
* `params.org`, and — for a write — an `org` inside the request body, which
* `unwrapBodyParam` flattens over the top level. Two different names are
* rejected rather than resolved: the same code path performs writes, and
* guessing wrong would write to the wrong organization.
*/
function orgError(entry: CatalogEntry, named: string[]): string | undefined {
const takesOrg = entry.parameters.some((p) => p.name === "org" && p.in === "path");
if (!takesOrg) {
if (named.length === 0) return undefined;
return `Action "${entry.id}" is not organization-scoped — it takes no org. Retry with no org in the org parameter, in params.org, or in the request body.`;
}

// Before any disagreement: an unusable value is the thing to report, and
// reporting it as a nameless org disagreeing with a real one would send the
// caller to drop one of the two rather than to fix the bad value.
if (named.includes("")) {
return "The org must be a single organization name, given as a non-empty string. Check the org parameter, params.org, and any org in the request body, or omit all of them to use the configured default.";
}

if (named.length > 1) {
const quoted = named.map((o) => `"${o}"`).join(" and ");
return `Conflicting orgs in one call: ${quoted}. The org parameter, params.org, and an org in the request body must agree — supply just one.`;
}

return undefined;
}

export async function executeAction(
catalog: CatalogEntry[],
config: Config,
Expand All @@ -64,6 +118,7 @@ export async function executeAction(
fields?: string[],
fetchFn: FetchFn = globalThis.fetch,
mode?: ToolMode,
org?: string,
): Promise<unknown> {
Comment on lines 113 to 122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor maintainability nit, pre-existing but worsened here: executeAction is now eight positional parameters, and both call sites in src/index.ts have to pass a literal undefined for fetchFn to reach past it:

await executeAction(entries, config, actionId, params, fields, undefined, "GET", org);

mode and org are now adjacent and both string-ish. mode is a narrow union so swapping them is caught, but passing a ToolMode value into the org slot compiles fine. An options object for the optional tail would make the call sites self-describing:

executeAction(entries, config, actionId, params, { fields, mode: "GET", org })

Not blocking, and out of scope if you'd rather keep the diff tight — but this is the second optional parameter added to the tail, so it's likely to come up again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the smell, declining for this PR. Reworking the signature touches both call sites and every test that reaches past fetchFn. That belongs in its own diff, where the churn is the change rather than noise around a behaviour fix.

Raising it as a follow-up issue.

no mutation: this reply adds no test claim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raised as #64, along with the same blind spot in unwrapBodyParam own body-field check.

no mutation: this reply adds no test claim.

const entry = catalog.find((e) => e.id === actionId);
if (!entry) {
Expand All @@ -84,8 +139,32 @@ export async function executeAction(
};
}

// Collect the org from every channel before deciding. `unwrapBodyParam`
// flattens a write's request body over the top level, so params.org has to be
// read before the unwrap and the body's org after it — otherwise the spread
// picks a winner and the disagreement is never seen. When the unwrap declines,
// `unwrapped` is `params` and the body's org is never read. That is correct:
// an unflattened body cannot reach the path, so there is nothing to check.
const beforeUnwrap = normalizeOrg(params.org);
const unwrapped = unwrapBodyParam(entry, params);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering is right and the test round-trip you added pins it. One seam worth a sentence, since the README now states the rule as an invariant.

The body channel only exists when unwrapBodyParam actually unwraps. When it bails — undeclared sibling key, entry takes no request body, a declared param genuinely called body — unwrapped === params, so normalizeOrg(unwrapped.org) re-reads params.org and the body's org is never looked at:

params: { org: "approved-org", unknown_key: 1, body: { org: "other-org", ... } }
  → siblings ["org", "unknown_key"]; unknown_key is not declared → no unwrap
  → named = ["approved-org"]          // "other-org" never seen, no conflict
  → POST /controls/approved-org  body {"body":{"org":"other-org",...},"unknown_key":1}

No safety problem, and I don't think it should change: the path org is still a value that was named and checked, and other-org rides along inside a payload the API will reject anyway. But README says "There are three ways to name an org in one call … They must agree", and here the third one silently isn't compared. The condition is non-obvious — it's a property of a different function, chosen for reasons that have nothing to do with orgs.

A line on the existing comment covers it:

Suggested change
const unwrapped = unwrapBodyParam(entry, params);
const unwrapped = unwrapBodyParam(entry, params);

…with the block above extended:

unwrapped is params when the unwrap declines (see unwrapBodyParam), so the body channel exists only when the body is flattened. That is the only case where a body org can reach the path, so it is also the only case worth cross-checking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken as the comment you suggested, near enough.

verified: sed -n 142,148p src/tools/execute-action.ts
// Collect the org from every channel before deciding. unwrapBodyParam
// flattens a write request body over the top level, so params.org has to be
// read before the unwrap and the body org after it — otherwise the spread
// picks a winner and the disagreement is never seen. When the unwrap declines,
// unwrapped is params and the body org is never read. That is correct:
// an unflattened body cannot reach the path, so there is nothing to check.

The class is a decline path that returns the params untouched. All five return the same object, so the comment covers every one:

search: grep -n "return params" src/tools/execute-action.ts
46 body is null, not an object, or an array
47 entry takes no request body
48 entry declares a parameter called body
51 the body schema declares a body property
54 an undeclared sibling key sits alongside body

Left the README alone. No catalog action declares a parameter named body, and no request body schema has a body property, so the unwrap only declines on a malformed call.

no mutation: comment only, no behaviour changed.

const named = [...new Set(
[normalizeOrg(org), beforeUnwrap, normalizeOrg(unwrapped.org)]
.filter((o): o is string => o !== undefined),
)];
Comment on lines +142 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read-before/read-after ordering is correct and the reasoning in the comment is exactly right — the spread really would pick a winner and hide the disagreement. Worth keeping.

One thing to consider, though, about treating the body as a legitimate third channel rather than just a thing to cross-check. When the body is the only place naming an org, named has one element, orgError is satisfied, and the write is retargeted to it — no top-level org, no params.org.

That's identical to pre-PR behaviour (unwrapBodyParam already spread it over the top level), so it's not a regression. But it sits slightly against the PR's stated goal: the target org is surfaced in the approval prompt because it's a top-level input, and this is precisely the case where it isn't. The user still sees it, just nested inside the params blob.

Since no catalog write body declares an org property (and they're additionalProperties: false), a body-only org is always spurious — so rejecting it, or at minimum not letting it silently decide the target, would close the loop rather than preserving the one path the PR set out to make visible. Your call; the current behaviour is defensible and documented. Just flagging that the strongest argument in the PR body ("the target is never guessed") applies here too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining. unwrapBodyParam exists to treat body keys as if they had been sent as top-level params. Under that contract a body-nested org is params.org. That is a supported channel, so rejecting it would contradict the function it just passed through.

The dangerous case is the one this PR closes: a body org disagreeing with a named one. A body-only org still goes through the trim, blank and non-org-scoped checks. The guard added above means a body field called org cannot arrive without a test failing.

The class here is "a channel that decides the target without being checked". So I searched every place an org value is read:

search: grep -rnE "(params|unwrapped|withOrg|config).org|normalizeOrg(|KOSLI_ORG" src/
client/kosli-client.ts:131 params.org ?? this.config.org (only reader that fills the path segment)
tools/execute-action.ts:135 normalizeOrg(params.org) (pre-unwrap)
tools/execute-action.ts:138 normalizeOrg(org), normalizeOrg(unwrapped.org)
tools/execute-action.ts:150 delete withOrg.org / withOrg.org = named[0]
config.ts process.env.KOSLI_ORG (the default; now trimmed)
index.ts config.org (description strings only)

Four channels reach line 131. All four pass through normalizeOrg first.

mutation: add an org property to allow_artifact_for_env in src/catalog.json -> "never declares org as a request body property" red


const message = orgError(entry, named);
if (message) return { error: true, message };

// The checked value is the one that goes out, so buildUrl cannot see a
// different org from the one that was approved. With nothing named, the key
// is dropped rather than left as a null that would ride along as a stray
// query parameter.
const withOrg = { ...unwrapped };
if (named.length === 0) delete withOrg.org;
else withOrg.org = named[0];

const client = new KosliClient(config, fetchFn);
const result = await client.execute(entry, unwrapBodyParam(entry, params));
const result = await client.execute(entry, withOrg);

// Never strip error shapes — pickFields would reduce them to {}
// and hide the failure from the LLM.
Expand Down
Loading