diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index a9975e7745..6c7c08e2b0 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -1,6 +1,6 @@ --- name: ocx -description: Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API. +description: "Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API." --- # Operating `ocx` @@ -90,6 +90,19 @@ starring would be useful, say so and let the user decide. The same boundary covers the session-gated `/api/codex-prompt` writes: read them with `ocx inspect codex-prompt`, and leave the writes to the dashboard. +## Secret-bearing commands + +**Do not run a command that creates an access key or starts a rotation.** This includes +`ocx access key create`, its `access keys` and `api-key` aliases, `ocx access key rotate `, +the equivalent `opencodex` forms, and direct `POST /api/keys` or `POST /api/keys/rotate` calls. +Their one-time responses contain a new plaintext data-plane credential, and command output can +enter the agent transcript. Ask the user to perform that step in a terminal outside the agent +session, configure the replacement, and confirm only that it is configured plus any non-secret key +or rotation id needed for follow-up. Never ask the user to paste the key into chat. Configuration +confirmation is not approval to revoke the existing credential. Before committing an in-place +rotation or removing an old key after a separately created replacement, identify the existing key +id that will be revoked and obtain separate explicit user approval immediately before that command. + ## Destructive verbs `storage trash restore` and `storage policy run` refuse without `--yes` (exit 2, nothing sent). diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 4f48d2bfd7..f67ff53d62 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -93,20 +93,47 @@ Read `accounts[]`. Two things to respect: `providers[]` and `models[]` carry `estimatedCostUsd`. Costs are estimates; `estimateReasons` in the log rows tells you why (for example `usage_estimated`, `expected_price_overlay`). -## 5. Rotate an access key and confirm it went quiet +## 5. Prepare an access-key rotation without exposing the new key ```bash ocx access key list --json -ocx access key create rotated --json # the plaintext key is in THIS response only +``` + +Creating a key or starting an in-place rotation returns a one-time plaintext credential. **Do not +run either secret-returning command from an agent session, including through the `access keys` or +`api-key` aliases, the `opencodex` executable, or direct management-API POSTs to `/api/keys` and +`/api/keys/rotate`:** terminal output can enter the model transcript. Ask the user to perform that +step in a separate terminal, configure the replacement, and report only that it is configured plus +any non-secret key or rotation id needed for follow-up. Never ask for the key itself. + +Configuration confirmation is not approval to revoke the existing credential. After confirmation, +identify the existing key id that will be revoked and obtain separate explicit user approval +immediately before one of the following paths. + +For an in-place rotation, commit the pending replacement on the same id: + +```bash +ocx access key rotate commit --json +``` + +For a separately created replacement key, remove the old id: + +```bash ocx access key remove --yes --json -ocx access key list --json # the old id is gone; check usage on the rest ``` -Note the argument style: `create ` and `remove ` are **positionals**, not `--label` and -`--id`. `remove` also refuses without `--yes`. +Then verify the matching result: + +```bash +ocx access key list --json +``` + +For an in-place rotation, the same id remains and `pendingRotation` disappears. For a separately +created replacement, the old id disappears; check usage on the remaining keys. Note the argument +style: `remove ` is positional, not `--id`, and refuses without `--yes`. The list carries per-key usage, so a key whose count stops advancing is genuinely unused. The -plaintext key appears once, in the `create` response, and is never retrievable again. +replacement key's plaintext is never retrievable after creation. An `ambiguous` footer on the list means two configured keys share an id, so per-key totals do not exist for them — do not attribute usage to either. diff --git a/tests/skill-ocx.test.ts b/tests/skill-ocx.test.ts index 41ddfb6af3..440eea3813 100644 --- a/tests/skill-ocx.test.ts +++ b/tests/skill-ocx.test.ts @@ -161,4 +161,57 @@ describe("the consent boundary is stated, not implied", () => { expect(recipes).toContain("PREVIEW"); expect(recipes).toContain("get approval"); }); + + const secretBearingAccessKeyCommand = + /\b(?:ocx|opencodex)(?:\.(?:exe|mjs))?["']?\s+(?:access\s+keys?|api-key)\s+(?:create\b|rotate\b(?!\s+(?:--json\s+)?(?:commit|abort)\b))/gim; + const secretBearingManagementRequest = + /(?:(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["'])[^\n]{0,240}\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])|\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])[^\n]{0,240}(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["']))/gim; + + function secretBearingCommandsInFences(text: string): string[] { + const matches: string[] = []; + for (const block of text.matchAll(/```[^\n]*\n([\s\S]*?)```/g)) { + // Fold common shell continuations so a wrapped command cannot evade the check. + const executableText = block[1]!.replace(/(?:\\|`|\^)\r?\n\s*/g, " "); + matches.push(...executableText.matchAll(secretBearingAccessKeyCommand).map(match => match[0])); + matches.push(...executableText.matchAll(secretBearingManagementRequest).map(match => match[0])); + } + return matches; + } + + test("the secret-bearing command detector covers aliases and shell wrappers", () => { + for (const command of [ + "ocx access key create rotated --json", + "& ocx access keys create rotated --json", + "opencodex api-key rotate old-id --json", + "ocx access key rotate --json old-id", + "ocx access key \\\n create rotated --json", + "curl -X POST http://127.0.0.1:3000/api/keys", + "Invoke-RestMethod http://127.0.0.1:3000/api/keys/rotate -Method Post", + ]) { + expect(secretBearingCommandsInFences("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + for (const command of [ + "ocx access key rotate commit old-id rotation-id", + "ocx access key rotate --json commit old-id rotation-id", + "ocx access key rotate --json abort old-id rotation-id", + ]) { + expect(secretBearingCommandsInFences("```bash\n" + command + "\n```"), command).toEqual([]); + } + expect(secretBearingCommandsInFences("```bash\ncurl -X POST http://127.0.0.1:3000/api/keys/rotate/commit\n```")).toEqual([]); + }); + + test("agent-facing guidance never exposes secret-bearing access-key commands", () => { + for (const file of ["SKILL.md", ...REFERENCES.map(ref => join("references", ref))]) { + expect(secretBearingCommandsInFences(read(file)), file).toEqual([]); + } + const skill = readFileSync(SKILL, "utf8"); + const recipes = read("references/03_recipes.md"); + expect(skill).toMatch(/outside the agent\s+session/); + expect(skill).toMatch(/configuration\s+confirmation is not approval/i); + expect(skill).toMatch(/separate explicit user approval/i); + const approvalAt = recipes.indexOf("obtain separate explicit user approval"); + expect(approvalAt).toBeGreaterThanOrEqual(0); + expect(approvalAt).toBeLessThan(recipes.indexOf("ocx access key rotate commit")); + expect(approvalAt).toBeLessThan(recipes.indexOf("ocx access key remove")); + }); });