Skip to content

fix(secrets): read one service's env with --service; refuse to run on an ambiguous name - #193

Merged
CarmenDou merged 7 commits into
mainfrom
feat/secrets-service-flag
Sep 10, 2026
Merged

fix(secrets): read one service's env with --service; refuse to run on an ambiguous name#193
CarmenDou merged 7 commits into
mainfrom
feat/secrets-service-flag

Conversation

@CarmenDou

@CarmenDou CarmenDou commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

The CLI half of InsForge/insta-platform#388. Reported in Slack as "admin username and password in my hermes's secrets disappeared" — nothing was deleted, but insta secrets --print returned codex's password under ADMIN_PASSWORD because three compute services each held their own and the flat read kept whichever row was newest.

Flags

command flag
insta secrets --service <type/name> — read one compute service's own env instead of the branch-wide bundle
insta secrets unset --service <type/name> — remove only that service's copy. Without it the delete is WIDE, not narrow: the platform's unscoped DELETE has no service_id predicate, so it removes every non-minted row for that (project, branch, name) — every service's copy. Verified on staging: three compute services each holding ADMIN_PASSWORD, one DELETE ?branch=main, all three gone. That is the long-standing backward-compatible behaviour, not something this PR changes; --service is what makes a narrow delete possible at all, and the CLI never exposed it before
insta run --service <type/name> — inject exactly what one compute service receives
insta run --ignore-collisions — run anyway, with every colliding name removed from the child environment

Every general (non---service) read now sends on_collision=withhold, so this CLI gets correct behaviour as soon as the platform ships. Until then the platform's default is merge and collisions is [], so nothing changes.

insta run refuses rather than warns

This is the important one. run.ts spawns with env: { ...process.env, ...bundle }, so a name missing from the bundle falls through to the parent process's value — a developer who once exported codex's password would run hermes' command against it while the CLI believed it had withheld the name.

Clearing is not sufficient either: it stops the parent's value being inherited and nothing more, since the child may hold a compiled-in default or load its own .env and proceed on some other value silently. So run prints the collisions and exits 2 having spawned nothing. Exit 2 is the repo's existing "gate" code — handleApproval and onError's AgentApprovalRequired both use it, for the same reasons (not success, so a redirected stdout cannot swallow it; not a plain error, since die owns 1). util.refuse() carries that shape.

--ignore-collisions proceeds but childEnv() deletes every colliding name, so the parent's export cannot stand in. The run banner moved to an announce callback fired only once we are actually spawning, so it can never precede a refusal.

--json stdout is deliberately unchanged

insta secrets --json stdout is byte-identical to before this branch — still the bare {NAME: value} map. Collisions ride stderr as one JSON line, {"collisions":[…]}, or nothing at all when there are none. stdout carries the payload, which is why secrets --print gives it the env and run gives it entirely to the child; and the flat map is the honest representation, because its destination is a .env file and a process environment, both flat. Under withhold every name in it has exactly one true answer, so nothing in it is a guess.

An earlier revision wrapped it as {secrets, collisions}. That broke every existing --json consumer including calls passing none of the new flags, for a signal that already had a home on stderr, so it was reverted. A test now pins the absence of envelope keys at the top level so it cannot come back unnoticed.

Testing

test/secrets-collisions.test.ts (new, 23 cases), written failing first. Highlights:

  • run refuses: the injected spawnImpl was never called and stdout is empty.
  • --ignore-collisions strips the name even with process.env.ADMIN_PASSWORD set — a real child that exits 7 only if the name arrived absent. This is the inheritance hole, proven closed.
  • --json keeps stdout the bare map with no envelope keys; reports collisions as a parseable stderr line; says nothing on stderr when there are none.
  • secrets --service / unset --service send the right query params; collisions never reach stdout.

npm run typecheck clean. npm test: 820 tests, 814 passed, 6 failed — the 6 are pre-existing in test/setup-agent.test.ts (setupAgentissueAgentSession makes a live POST /agent/sessions against the prod API, which 404s); verified identical on untouched origin/main.

The cli-reference.md mirror — done, in InsForge/instacloud-skills#80

AGENTS.md non-negotiable #4 requires it, and it cannot be in this change set: skills/insta/cli-reference.md lives in a different repository (InsForge/insta-skills, an insta-cloud submodule) whose main is protected too. So it is a same-change-set pair, not a same-commit edit:

this PR the flags and behaviour
InsForge/instacloud-skills#80 cli-reference.md (the secrets, secrets unset and a new insta run row), SKILL.md, and four reference pages

That PR also corrects a claim that had been wrong since platform #323: four places said provider credentials never reach insta secrets / insta run, which stopped being true when the general read began carrying the branch's canonical credentials.

Merge order, per the platform PR's spec: frontend #400 (independent) → platform #388 → platform deployed → this PR → released → #80. The release must follow the platform deploy: a released CLI sends on_collision=withhold, and a platform that does not yet know the param ignores it and returns no collisions, which this CLI would read as "nothing collided".

Still not mirrored, tracked for that same docs PR: skills/insta/SKILL.md's insta run cheat-sheet line.

🤖 Generated with Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/secrets.ts">

<violation number="1" location="src/commands/secrets.ts:54">
P2: The collision guard fails open on platforms that do not honor the new `on_collision=withhold` query param. Every general read now sends `on_collision=withhold`, so the safety of `insta run` (and the .env write) rests entirely on the platform honoring it. The PR's own rollout note says non-service reads still default to merge until the platform ships withhold; on such a platform the response is the merged value with no `collisions` field, and `?? []` reads that as "no collisions". `runWithSecrets` then spawns the child with the merged (leaking) value and no refusal — exactly the leak this change is meant to close — because "platform withheld, none collided" and "platform merged, no report" are indistinguishable. Consider failing closed (e.g. surfacing a warning that withhold couldn't be confirmed) until the platform feature is guaranteed, rather than treating an absent `collisions` field as proof the read was safe.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/commands/run.ts
Comment thread src/commands/secrets.ts
Comment thread src/commands/secrets.ts
): Promise<SecretBundle | null> {
const res = await api.rawRequest('GET', `/projects/${projectId}/secrets${bundleQuery(opts)}`)
if (handleApproval(res, opts.json)) return null
return { secrets: res.body.secrets as Record<string, string>, collisions: (res.body.collisions ?? []) as Collision[] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The collision guard fails open on platforms that do not honor the new on_collision=withhold query param. Every general read now sends on_collision=withhold, so the safety of insta run (and the .env write) rests entirely on the platform honoring it. The PR's own rollout note says non-service reads still default to merge until the platform ships withhold; on such a platform the response is the merged value with no collisions field, and ?? [] reads that as "no collisions". runWithSecrets then spawns the child with the merged (leaking) value and no refusal — exactly the leak this change is meant to close — because "platform withheld, none collided" and "platform merged, no report" are indistinguishable. Consider failing closed (e.g. surfacing a warning that withhold couldn't be confirmed) until the platform feature is guaranteed, rather than treating an absent collisions field as proof the read was safe.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/secrets.ts, line 54:

<comment>The collision guard fails open on platforms that do not honor the new `on_collision=withhold` query param. Every general read now sends `on_collision=withhold`, so the safety of `insta run` (and the .env write) rests entirely on the platform honoring it. The PR's own rollout note says non-service reads still default to merge until the platform ships withhold; on such a platform the response is the merged value with no `collisions` field, and `?? []` reads that as "no collisions". `runWithSecrets` then spawns the child with the merged (leaking) value and no refusal — exactly the leak this change is meant to close — because "platform withheld, none collided" and "platform merged, no report" are indistinguishable. Consider failing closed (e.g. surfacing a warning that withhold couldn't be confirmed) until the platform feature is guaranteed, rather than treating an absent `collisions` field as proof the read was safe.</comment>

<file context>
@@ -4,23 +4,99 @@ import { join } from 'node:path'
+): Promise<SecretBundle | null> {
+  const res = await api.rawRequest('GET', `/projects/${projectId}/secrets${bundleQuery(opts)}`)
+  if (handleApproval(res, opts.json)) return null
+  return { secrets: res.body.secrets as Record<string, string>, collisions: (res.body.collisions ?? []) as Collision[] }
+}
+
</file context>

Comment thread src/commands/secrets.ts Outdated
CarmenDou added a commit that referenced this pull request Sep 9, 2026
… its branch

Three review findings from #193, all of which reproduce.

childEnv deleted colliding names by exact key. The spread that builds the child environment copies
process.env into a PLAIN object, which loses the case-insensitivity Windows env names have — while
CreateProcess keeps it. So a parent that exported `Admin_Password` survived a delete of
`ADMIN_PASSWORD` and the child read it as ADMIN_PASSWORD: the inheritance hole this branch exists
to close, still open on one platform. The delete is now case-insensitive on win32 and stays exact
elsewhere, where `Admin_Password` is a different variable that is none of our business. The
platform is a parameter, not `process.platform`, so both branches run on every CI host — the
Windows job could not have caught this anyway, since the tests inject spawnImpl and no real child
environment is ever read.

`secrets unset --service` sent no branch when --branch was omitted. A service exists on a branch,
so the platform rejects that pair — the flag could not delete a single service's copy at all
without a second flag. It now defaults to the linked branch exactly as `secrets set --service`
does, and both the human line and --json report the effective branch, so the user can see which
scope was actually deleted.

The remediation hints dropped an explicit --branch: after `insta secrets --branch feat-x`, the
suggested `insta secrets --service compute/hermes` would have read the linked branch instead —
different secrets, no sign of the swap. collisionLines and run's refusal now name the branch
whenever it is not the linked one.

Not addressed, deliberately: a platform that ignores on_collision returns a merged value with no
`collisions` field, which `?? []` reads as "nothing collided". That fails open. It is resolved by
shipping order — insta-platform#388 merges before this CLI is released — per the repo owner, and
fetchSecretBundle now carries a comment saying so, so nobody later reads that fallback as
unconditionally safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary
The code paths for collision handling look sound, but the PR is missing the repo-required command reference update for the new flags.

Requirements context
I based intent on the PR description at #193, the new README behavior notes in README.md:113-128, and the repo guidance in AGENTS.md:15-17 / .claude/skills/developing-insta-cli/SKILL.md:36-38. The linked platform PR InsForge/insta-platform#388 was not accessible from this environment: https://github.com/InsForge/insta-platform/pull/388 returned 404, so I assessed the platform dependency from the PR description alone.

Findings

Critical:

  • AGENTS.md:15-17, .claude/skills/developing-insta-cli/SKILL.md:36-38, src/index.ts:93-99, src/index.ts:176-188 — The PR adds public flags on insta run, insta secrets, and insta secrets unset, but the required skills/insta/cli-reference.md mirror is not included in this change. The repo guide calls command/flag reference updates non-negotiable and says new or renamed flags are only half-done until that document is updated in the same change set. The PR description also lists this as outstanding, so this blocks merge under the stated repo requirements.

Suggestion:

  • src/commands/secrets.ts:166-172 — Consider applying the new empty---service guard to the existing secrets set --service path too. This PR correctly guards the new read/unset/run service paths, but secrets set NAME value --service "" still falls through as an unscoped write because the body only includes service when the option is truthy.

Information:

  • src/commands/secrets.ts:36-61, src/commands/run.ts:87-105, test/secrets-collisions.test.ts:43-385 — Software engineering/functionality coverage is strong for the changed behavior: query construction, stderr-only collision reporting, service-scoped fetch/delete, refusal without spawn, --ignore-collisions, and Windows case-insensitive env handling are all covered by focused tests.
  • src/commands/secrets.ts:36-61, src/commands/run.ts:50-102 — No security-relevant issue found in the changed code: user-controlled branch/service/name values are URL-encoded for HTTP paths/query params, no secret values are newly logged, and no new dependencies are introduced.
  • src/commands/secrets.ts:72-90, src/commands/run.ts:50-69 — No performance issue found; the new work is bounded formatting plus small environment-object/set operations on CLI command paths.
  • Local verification could not complete in this checkout: npm run typecheck failed with tsc: not found, and the affected Vitest run could not load vitest/config because dependencies are not installed. git diff --check main...HEAD passed.

Verdict
Request changes. The code implementation is otherwise reviewable, but the required CLI reference mirror needs to land with the flag change or as a clearly linked same-change-set PR before this should merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/run.ts">

<violation number="1" location="src/commands/run.ts:64">
P1: When a Windows bundle contains names that differ only by case, this loop injects both variants instead of resolving the ambiguity. Detect and withhold case-insensitive duplicates before spawning so the child cannot receive an implementation-dependent secret.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/commands/run.ts Outdated
for (const key of Object.keys(env)) if (claimed.has(key.toLowerCase())) delete env[key]
// A withheld name stays gone in every casing — including one the bundle itself carried, which
// is a platform that answered `collisions` while still merging the values.
for (const [k, v] of Object.entries(bundle)) if (!withheld.has(k.toLowerCase())) env[k] = v

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a Windows bundle contains names that differ only by case, this loop injects both variants instead of resolving the ambiguity. Detect and withhold case-insensitive duplicates before spawning so the child cannot receive an implementation-dependent secret.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/run.ts, line 64:

<comment>When a Windows bundle contains names that differ only by case, this loop injects both variants instead of resolving the ambiguity. Detect and withhold case-insensitive duplicates before spawning so the child cannot receive an implementation-dependent secret.</comment>

<file context>
@@ -38,24 +38,33 @@ export function bundleFetcher(
+    for (const key of Object.keys(env)) if (claimed.has(key.toLowerCase())) delete env[key]
+    // A withheld name stays gone in every casing — including one the bundle itself carried, which
+    // is a platform that answered `collisions` while still merging the values.
+    for (const [k, v] of Object.entries(bundle)) if (!withheld.has(k.toLowerCase())) env[k] = v
     return env
   }
</file context>

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.

Not taken, and the reasoning is recorded in the code rather than only here.

The mechanism is real: on win32 that loop would write both variants and CreateProcess would resolve one of them arbitrarily. The input is not reachable, though — two keys differing only by case requires a lowercase letter, and the platform's name rule (^[A-Z][A-Z0-9_]{0,63}$, enforced on secrets set, bind, GitHub connect and template manifest) forbids one. Minted names are the fixed uppercase constants in CANONICAL_KEYS.

Detecting and withholding an impossible input would add a branch and a set to a function whose whole job is to be auditable, so d1d2c34 documents the invariant where it would bite instead: if that name rule ever loosens, this loop is the place both variants get written and Windows picks one arbitrarily.

Contrast with the __proto__ finding on the same line, which I did take: there the fix is one call and costs nothing to carry, so the reachability argument does not have to hold for it to be worth having.

Comment thread src/commands/run.ts Outdated
@CarmenDou

Copy link
Copy Markdown
Contributor Author

Both addressed in bef0a35, and the Critical was a stale PR description rather than a missing mirror.

Critical — the cli-reference.md mirror. It is done: InsForge/instacloud-skills#80, covering the secrets / secrets unset rows, a new insta run row (there was none — run only appeared in prose), and four reference pages. It cannot live in this change set because that file is in a different repository (InsForge/insta-skills, an insta-cloud submodule) whose main is protected too, so it is a same-change-set pair, not a same-commit edit. The PR description said "outstanding" because I wrote it before that PR existed; it now links it and states the merge order. Fair finding on what the description claimed.

Suggestion — secrets set --service "". Fixed, and you were right that it needed the same guard. The stakes are higher than on the read paths: the scoping test is a truthiness check, so --service '' fell through to a project-wide write — a secret the caller asked to narrow to one service, stored where every service on the branch can see it. Guarded now, asserted through secretsSet rather than the helper so the wiring is pinned, and verified by mutation (removing the guard turns the case red).

One thing your review helped me find

Your requirements note quoted the flag as "reading/injecting exactly one compute service's env" — which is what the help text said, and it is misleading. --service reads one service's slice of the branch bundle, and it is deliberately wider than the deployed env: it also carries the branch's canonical provider credentials, which a container gets only where explicitly bound.

Two reviewers independently read the container meaning into that sentence and filed the resulting mismatch as a finding — one on the platform PR's API text, one on the docs mirror. So the help string was the shared source. Both --service help strings now say what the read is and what it is not.

On the verification note: dependencies are not installed in that checkout — npm ci makes tsc and vitest available. My run: typecheck clean, 36 collision tests passing, suite at the 6-failure test/setup-agent.test.ts baseline (that suite makes a live POST /agent/sessions against the prod API, which 404s; identical on untouched main).

CarmenDou and others added 6 commits September 9, 2026 16:37
…us name

`GET /secrets` answers with a flat `{ secrets }` map, but env is scoped per compute service, so
hermes, claude-code and codex can each hold their own ADMIN_PASSWORD. One name cannot carry three
values: the platform's read overwrote, newest row winning, and `insta secrets --print` showed
codex's password under ADMIN_PASSWORD while the hand-set hermes value was nowhere. It was reported
as "my hermes password disappeared".

The platform's fix (branch fix/secret-service-scoped-read) withholds a colliding name and reports it
as `collisions: [{ name, services }]`. This is the client half:

- every general read now sends `on_collision=withhold`, so it gets the correct answer as soon as the
  platform ships and the old merge behaviour is never asked for again;
- `--service <type/name>` on `insta secrets`, `insta run` and `insta secrets unset` addresses one
  service — the read returns exactly what that service receives, and the unset removes only its copy
  (the DELETE has always honoured ?service=, the CLI simply never exposed it);
- every collision is reported on stderr, never stdout: `secrets --print` writes the env to stdout and
  `insta run`'s stdout belongs entirely to the child. `secrets --json` carries the list too, which
  moves the values one level down into `secrets` — the one breaking change here.

`insta run` REFUSES rather than warns, because a warning would not be safe. The child is spawned with
`env: { ...process.env, ...bundle }`, and a withheld name is simply MISSING from the bundle — so the
parent's environment supplies it, and a developer who once exported codex's password would run
hermes' command against it with nothing on screen saying so. Clearing the name instead of inheriting
it is also not enough: the child may hold a compiled-in default or load its own .env, so a credential
that silently went missing need not be observable from inside the command at all. The only honest
answer is to stop before anything runs and make the user choose — `--service` to scope the run, or
`--ignore-collisions`, which runs with every colliding name deleted from the child environment (so
the parent's value still cannot stand in for it) and says so on stderr.

The refusal exits 2, the code handleApproval already uses for a gate: nothing ran, a redirected
stdout must not read it as success, it is not a plain failure (die owns 1), and re-running as the
message says will work. util.refuse() carries that shape.

The platform is not deployed yet, so the tests drive injected responses through RunDeps and a stub
api: run refuses and spawns nothing; --ignore-collisions spawns with the colliding name absent even
though process.env holds a value for it (the regression above, pinned); --service fetches the scoped
URL; collisions land on stderr and never stdout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…branch

The platform 400s a `service` query param that arrives empty, and the client built the general
query whenever the flag was falsy — so `--service ""`, or the ordinary `--service "$SVC"` with SVC
unset, quietly answered a different question: the branch-wide merge instead of one service's env.
On `insta secrets` that writes the ambiguous bundle to .env with only a stderr note; the failure
this whole change exists to remove.

assertServiceRef fails locally on secrets, secrets unset and run, naming the shape it wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit wrapped `insta secrets --json` in `{secrets, collisions}` so a machine reader
could see a withheld name. That broke a documented agent-facing surface for every consumer,
including calls that pass none of the new flags and would never see a collision — the bare
`{NAME: value}` map is what they parse.

The envelope only existed because the --json branch returned before warnCollisions, leaving that
mode with no collision signal at all: a return-order problem, not a reason to change the contract.
So --json now reports the collisions the same way every other mode does — on stderr, as one
`{"collisions":[…]}` line — and stdout stays exactly what it was. Nothing is written when there
are no collisions; a quiet stream is the signal, and an empty array would only make every caller
inspect a field that says nothing.

This is the rule the rest of the change already followed: stdout belongs to the payload, which is
why `secrets --print` gives it the env and `insta run` gives it entirely to the child. The --json
branch was the one place it was not applied.

Tests pin the contract in both directions: stdout carries no `secrets` or `collisions` key on top,
so the envelope cannot come back unnoticed, and the stderr line parses on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… its branch

Three review findings from #193, all of which reproduce.

childEnv deleted colliding names by exact key. The spread that builds the child environment copies
process.env into a PLAIN object, which loses the case-insensitivity Windows env names have — while
CreateProcess keeps it. So a parent that exported `Admin_Password` survived a delete of
`ADMIN_PASSWORD` and the child read it as ADMIN_PASSWORD: the inheritance hole this branch exists
to close, still open on one platform. The delete is now case-insensitive on win32 and stays exact
elsewhere, where `Admin_Password` is a different variable that is none of our business. The
platform is a parameter, not `process.platform`, so both branches run on every CI host — the
Windows job could not have caught this anyway, since the tests inject spawnImpl and no real child
environment is ever read.

`secrets unset --service` sent no branch when --branch was omitted. A service exists on a branch,
so the platform rejects that pair — the flag could not delete a single service's copy at all
without a second flag. It now defaults to the linked branch exactly as `secrets set --service`
does, and both the human line and --json report the effective branch, so the user can see which
scope was actually deleted.

The remediation hints dropped an explicit --branch: after `insta secrets --branch feat-x`, the
suggested `insta secrets --service compute/hermes` would have read the linked branch instead —
different secrets, no sign of the swap. collisionLines and run's refusal now name the branch
whenever it is not the linked one.

Not addressed, deliberately: a platform that ignores on_collision returns a merged value with no
`collisions` field, which `?? []` reads as "nothing collided". That fails open. It is resolved by
shipping order — insta-platform#388 merges before this CLI is released — per the repo owner, and
fetchSecretBundle now carries a comment saying so, so nobody later reads that fallback as
unconditionally safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit made a WITHHELD name win over whatever casings the parent had. An injected one
still did not, and that is the more consequential half: injecting cloud credentials is what
`insta run` is for. On win32 `{ ...parent, ...bundle }` can hold `Database_Url` from the shell and
`DATABASE_URL` from the bundle as two keys for one variable, and the choice between them is
CreateProcess's, not ours — so a stale shell export could quietly beat the fetched credential, for
every variable rather than only a colliding one.

childEnv now takes both halves together on win32: every name this run decides — injected or
withheld — is removed from the parent in all its casings, then the bundle is written back in its
own casing. A withheld name is not written back at all, including one a merging platform carried in
the bundle, so it stays gone in every casing. Off win32 nothing changes; POSIX names really are
case-sensitive, and collapsing `Database_Url` into `DATABASE_URL` there would be a bug of our own.

Stated plainly: this is NOT verified against a real Windows host. The mechanism is the one from the
finding confirmed by inspection — a plain JS object does not carry Windows' case-insensitive name
semantics, while CreateProcess does — but no test here executes CreateProcess. The tests pin OUR
normalisation (one key per variable, in our casing, with our value) rather than Windows'
resolution, and they take the platform as a parameter so both branches run on every CI host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ling the read a container env

Two review findings.

`secrets set --service ""` fell through to a PROJECT-WIDE write. The scoping
test is a truthiness check, so a client interpolating an absent variable put the
secret at a WIDER scope than it asked for — visible to every service on the
branch. The read paths were already guarded; the write is the one where getting
it wrong actually stores something in the wrong place. Guarded now, asserted
through `secretsSet` rather than the helper so the wiring is pinned, and
verified by mutation: removing the guard turns the case red.

`insta run --service`'s help said "inject exactly what one compute service
RECEIVES". Two reviewers independently read that as the container's env and
filed the resulting mismatch as a finding — on this PR and on the docs mirror.
The read is one service's slice of the branch bundle and deliberately WIDER
than the deployed env: it also carries the branch's provider credentials, which
a container gets only where bound. Both `--service` help strings now say so, so
the misreading has no source left.

typecheck clean; 36 collision tests; suite at the 6-failure setup-agent.test.ts
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CarmenDou
CarmenDou force-pushed the feat/secrets-service-flag branch from bef0a35 to 9c1f6e3 Compare September 9, 2026 23:38
…name survives

Automated review flagged two things about the win32 reconstruction. One is
taken; the other is unreachable and is now documented rather than coded around.

TAKEN — a bundle key of `__proto__` would set the prototype under `env[k] = v`
instead of creating an entry, and the secret would vanish with no error. The
platform's name rule (`^[A-Z][A-Z0-9_]{0,63}$`, enforced on all four write
paths) makes that unreachable today, and that is precisely why it is worth one
`Object.defineProperty` rather than a trusted invariant: this CLI points at
whatever `INSTA_API_URL` names, including a self-hosted insta-oss daemon whose
validation is not this repo's to guarantee. A security property should not rest
on someone else's regex when the alternative costs one call.

NOT TAKEN — two bundle keys differing only by case, which the win32 loop would
write both of. The mechanism is real; the input is not. Case-differing needs a
lowercase letter and the same name rule forbids one. Detecting and withholding
an impossible input would add a branch and a set to a function whose whole job
is to be auditable, so the invariant is written where it would bite instead:
if that rule ever loosens, this loop is the place both variants get written and
Windows picks one arbitrarily.

The test for this needed fixing before it tested anything: `{ __proto__: 'v' }`
as an object literal is the prototype-setting syntax, not a key — and with a
string value the spec ignores it outright — so the bundle was simply empty. Built
with `JSON.parse('{"__proto__":"v"}')` now, asserted on both platform branches
(the non-win32 spread creates the own key via CopyDataProperties), and verified
by mutation: restoring `env[k] = v` turns the win32 case red.

Also rebased onto main (0.0.64 released as insta-cli#194): typecheck clean and
850/850 tests, the six `setup-agent.test.ts` failures that were this branch's
baseline having been fixed on main in the meantime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary
The runtime changes look aligned with the PR intent, with no blocking correctness, security, or performance findings.

Requirements context
I used the PR title/description, the README updates in this repo, the existing command conventions in src/index.ts, and the checked-in tests. The linked platform PR #388 was not publicly accessible from this environment, so I assessed that part against the PR description. The intended behavior is: general bundle reads ask for on_collision=withhold; secrets/run can read one service via --service; secrets unset --service deletes only that service's copy; run refuses with exit 2 on collisions unless --ignore-collisions; collision reports stay off stdout.

Findings

Critical
(none)

Suggestion

  • README.md:115-120 still says insta secrets / insta run fetch only user-defined secrets and that provider-minted credentials are not in the bundle. That conflicts with the new CLI help's local-seam distinction in src/index.ts:97 and with the linked docs PR's stated correction that local secrets / run now carry canonical provider credentials while compute containers receive provider credentials only through explicit bindings. Since this PR edits the same README section, consider updating that stale paragraph here too.

Information

  • src/commands/secrets.ts:45-61 intentionally treats a missing collisions field as no reported collisions. That preserves behavior against older platforms, but the safety guarantee depends on the rollout order described in the PR: platform #388 must be deployed before this CLI is released.
  • Software engineering: the new tests cover query construction, stderr/stdout separation, run refusal before spawn, --ignore-collisions env stripping, Windows case-insensitive env handling, and service-scoped unset/read behavior.
  • Security: no secret values are newly logged; collision reporting exposes names and service refs only, and service/branch values are URL-encoded before reaching the API.
  • Performance: no concerning performance changes; the added work is one existing bundle API request plus small linear scans over environment/secrets/collisions.
  • Verification: I did not run the test suite because this review was explicitly read-only and node_modules is absent in the checkout; I did run git diff --check main...HEAD, which was clean.

Verdict
Approved per the requested verdict rule: there are no Critical findings. This is a bot comment verdict, not a human GitHub approval.

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM - approved.

@CarmenDou
CarmenDou merged commit 533df64 into main Sep 10, 2026
3 checks passed
CarmenDou added a commit that referenced this pull request Sep 10, 2026
Ships the service-scoped secret reads from #193: --service on secrets, run and
unset; run refusing on a same-name collision with --ignore-collisions as the
escape hatch; collisions reported on stderr.

The platform side (InsForge/instacloud-platform#388) is already merged and DEPLOYED
to production, which this release depends on: the CLI sends
on_collision=withhold on every general read, and a platform that did not know
the param would ignore it, answer merged and return no collisions — which the
CLI reads as 'nothing collided'. Verified live before cutting this:
GET /projects/{id}/secrets on api.instacloud.com advertises service and
on_collision, and its 200 carries collisions.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants