Building Idira (CyberArk) ISP PowerShell tooling with Claude Code, Context7 MCP, and SecretStore
Coding agents write competent PowerShell against the CyberArk (now Idira) REST APIs but often fall short with Idira endpoints. Left to its own devices, the model will invent a token endpoint, treat the Identity tenant ID and the ISP subdomain as one string, and hardcode a client secret into the script the model just produced. Those are context failures, and no prompt fixes the gap one message at a time.
So we give the model the context up front. We will build one small PowerShell tool that authenticates to the Idira Identity Security Platform (ISP) and proves a token works, and the tool itself is almost incidental. What earns the reliability is the work we do around it: we feed the model the real API documentation with Context7, we write the platform's quirks into a CLAUDE.md so the model stops guessing them, we give it the tenant's non-secret details as config, then we keep the service account secret where the model never gets the cleartext. The harness here is Claude Code, but the technique is about agentic coding in general, so the pieces port to whatever harness you run.
Why letting the model guess is the wrong default
The failure modes here are not subtle, but each one looks correct until something returns a 404 or a malformed request error. The model derives the Idira service host from the login domain, because for most SaaS products the two match.
For Idira ISP they do not. The model also typically reaches for the PAM self-hosted logon flow when the tenant is really ISP. PAM self-hosted's logon API endpoint is hosted by the PVWA, while ISP gets a platform token from Idira Identity with a client-credentials grant, so a script that worked against a PAM self-hosted instance fails when authenticating against an ISP tenant. The model writes the secret straight into the script, or into a .env that the model then reads back, which in an agentic loop puts the secret into the model's context, or the model's logs.
We will not fix any of this one prompt at a time. The way out is to make the right information cheap for the model to pull, and to keep the dangerous information out of the files and context the model touches -- and, where that is not enough, behind a hard stop it cannot cross.
Feeding the model real docs with Context7
Context7 is an up-to-date documentation source for coding agents: give it a library name and it returns that library's current docs as snippets the model can read on demand. It plugs into a harness as an MCP (Model Context Protocol) server or as a Skill; here we call it as an MCP server. For us the value is simple: the model stops guessing the ISP auth flow and reads the flow straight from the source documentation. Pointed at the Idira API docs, the library resolves cleanly:
Context7:resolve-library-id "api-docs.cyberark.com"
-> /websites/api-docs_cyberark (CyberArk API, 80 snippets, source reputation: High)
When we query that library for the platformtoken flow, we get the actual endpoint contract rather than a plausible reconstruction:
POST https://<identity-tenant-id>.id.cyberark.cloud/oauth2/platformtoken
content-type: application/x-www-form-urlencoded
grant_type=client_credentials
client_id=<service-account login name>
client_secret=<service-account password>
200 OK
{ "access_token": "eyJh...", "token_type": "Bearer", "expires_in": 900 }
The model pulling the platformtoken contract from Context7's /websites/api-docs_cyberark library rather than generating it.
The request body is an ordinary OAuth 2.0 client-credentials grant, the same call we would make with cURL. The only parts specific to ISP are the host we send the grant to and where the token works afterwards, and those are the two details the model gets wrong from memory. Pulling them from api-docs.cyberark.com removes the guesswork.
Context7 helps beyond the language we happen to be writing in. It also indexes the libraries next to our task:
/pspete/pspas psPAS (2305 snippets, High)
/pspete/identitycommand IdentityCommand (75 snippets, High)
/cyberark/idsec-sdk-golang CyberArk's official Go SDK
We write PowerShell here, but the model can learn from more than PowerShell, because the platformtoken call is the same HTTP whether we express the call in Go, in PowerShell, or as a cURL example sitting in the documentation.
The cleanest reference implementation of the ISP authenticator lives in the idsec-sdk-golang NewIdsecISPAuth flow, so we let the model read how that SDK models authentication and translate the pattern into the Invoke-RestMethod call we want. psPAS and IdentityCommand serve the same purpose closer to home: proven patterns the model can lift instead of improvise.
None of this means we should pipe every library through Context7, since coverage varies and what comes back is only ever as good as the underlying documentation. We still give the model the real reference instead of hoping the model remembers, and Context7 makes that cheap.
Encoding the idiosyncrasies in CLAUDE.md
Context7 gives the model the facts. CLAUDE.md gives the model the rules that hold for this platform and this tenant, the ones no generic documentation will surface, because they are the assumptions a careful engineer would otherwise make and get wrong. Claude Code reads CLAUDE.md from the project root at the start of every session; this is an agentic-coding technique rather than a Claude Code one, so other harnesses read an equivalent file -- Codex and several others look for AGENTS.md -- and the rules port even where the filename does not.
The most valuable rule we can write is the one about hosts. The Identity tenant ID is not an Idira ISP's subdomain. The portal might be acme.cyberark.cloud and the Idira ISP's APIs acme.dpa.cyberark.cloud, both built from the same subdomain, but the Identity host that serves the token endpoint is something like abc1234.id.cyberark.cloud, built from a different identifier entirely. The model assumes one identifier covers all three hosts, and because the token host uses its own, deriving it from the subdomain produces a DNS failure long before we ever reach the credentials.
Here is the CLAUDE.md we hand the model:
# Idira (formerly CyberArk) ISP — project rules
## Branding
- CyberArk is now Idira. URLs and APIs are UNCHANGED. Do not rename `*.cyberark.cloud`, `api-docs.cyberark.com`, or the `cyberark/*` SDK repos.
## Hosts — two identifiers, three hostnames. Never derive one from another.
- IDENTITY_TENANT_ID and SUBDOMAIN are non-secret config; read them from .env. They are not secrets: do not hardcode them and do not put them in the vault.
- IDENTITY_TENANT_ID -> token endpoint host: <IDENTITY_TENANT_ID>.id.cyberark.cloud
- SUBDOMAIN -> service API hosts: <SUBDOMAIN>.<service>.cyberark.cloud
- DO NOT assume IDENTITY_TENANT_ID == SUBDOMAIN. **They are ALWAYS DIFFERENT.** Treat both as required inputs; never infer the service host from the login domain.
## Authentication — client_credentials, service user
- This is an ISP (SaaS) tenant. Authenticate with the OAuth2 client_credentials grant. Pull the exact token endpoint, re quest body, and response shape from the api-docs.cyberark.com reference through Context7; do not write them from memory.
- The credential is a SERVICE USER (an OAuth confidential client): its login name is the client_id and its password is the client_secret. A regular, interactive Cloud Directory user authenticates through a different CyberArk Identity flow, so do not use that flow here.
- Tokens are short-lived. Re-authenticate on expiry; never cache a token for the life of a long-running job.
## API endpoints — read them from Context7, never from memory
- Look up EVERY Idira API endpoint you call (path, method, query string, and request/response shape) from api-docs.cyberark.com through Context7 — not just the token endpoint, every service API too.
- api-docs.cyberark.com does not cover every Idira service. When an endpoint is not there, pull it from another Context7 library that does — `psPAS`, `IdentityCommand`, or the `idsec-sdk-golang` SDK — instead of guessing it.
- The docs are not infallible: what Context7 returns can still be wrong or incomplete. Use the retrieved form, but flag any endpoint you cannot confirm for live verification against the tenant -- never commit an unverified path silently.
## Do NOT cross the streams
- DO NOT use the self-hosted Vault flow `/PasswordVault/API/auth/CyberArk/Logon`. This is a SaaS (ISP) tenant; the two flows are not interchangeable and will fail against the wrong base URL.
## Secrets
- The service-account client_id/secret come from the SecretManagement vault at runtime via Get-Secret. NEVER hardcode them, write them to a file, echo them, or commit them. Do not read the SecretStore vault file.
The host rules and the ISP-vs-self-hosted guardrail are the two the model gets wrong most often.
Every block in there exists because I have watched a model make that mistake. The branding note stops the model from helpfully renaming working URLs, and the host rules are there because the Identity-tenant-ID-equals-Idira-subdomain assumption is the single most common way these scripts fail. The endpoints rule is there for the same reason: a model will otherwise reach for a plausible API path from memory and get it subtly wrong, and a wrong path only fails when the script runs, so every endpoint has to come from the docs through Context7 instead of from recall. The cross-the-streams rule stops the model from authenticating an ISP tenant as though the tenant were a PAM self-hosted deployment.
One caveat matters here: the official docs are careful to say that Claude Code treats CLAUDE.md as context, not enforced configuration, which means every rule in the file guides the model without binding it. The host rules make the model far less likely to conflate the two identifiers, but the rules are guidance, not a guarantee, and the same holds for the "do not read the vault file" line.
When we need a hard stop instead of a strong nudge, and for a credential store we should, the docs point to a PreToolUse hook that denies the matching tool call in code rather than by persuasion. A hook is only as tight as the patterns you give it: match every path to the credential, or the gap you leave is the bypass. Hooks are Claude Code's enforcement mechanism; other harnesses expose their own, or none, in which case the boundary has to live outside the harness. CLAUDE.md shapes the model's behavior, and a hook enforces the limit in code.
Keeping the service-account secret out of the model's reach
The credential we are protecting is an Idira Cloud Directory service user -- Identity's name for a service account -- configured as an OAuth confidential client. We create the service user in the Identity Administration portal, tick Is OAuth confidential client, and grant the least privilege the integration needs. The Add service users docs walk through the steps, after which the service user's login name becomes the client_id and the password becomes the client_secret.
The service user configured as an OAuth confidential client.
A .env file does not protect the secret here, and the reason is specific to agentic coding rather than plain hygiene: the model can read the file. Claude Code opens files and writes code that prints whatever the code loaded. A plaintext secret then sits squarely inside the model's reach.
💡 The risk is not that the secret leaks to git, since we already gitignore
.env. The risk is the model itself reading, printing, or committing the secret while doing exactly what we asked, landing the cleartext in its context or a transcript. So we keep the secret out of any file the model has reason to open, and we lean on the harness permission controls so the model will not read the credential store or run the authenticated command in a shell whose environment the model then dumps.
I use one of two options here, depending on what the project already runs.
The first-party choice is PowerShell's own SecretManagement with the SecretStore extension (two Microsoft modules, no third-party service). SecretStore keeps secrets in a local file that is encrypted with .NET cryptographic APIs and unlocked by a vault password. The model can see the vault file, but the file is ciphertext, and the model never holds the password that unlocks the vault, which is the whole reason this arrangement survives an agent with read access to the project. We set this up once:
Install-Module Microsoft.PowerShell.SecretManagement -Scope CurrentUser
Install-Module Microsoft.PowerShell.SecretStore -Scope CurrentUser
Register-SecretVault -Name idira -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
# Stored once, interactively. The values never appear in the script.
Set-Secret -Vault idira -Name idira-client-id -Secret 'svc-automation@cyberark.cloud'
Set-Secret -Vault idira -Name idira-client-secret -Secret (Read-Host -AsSecureString)
The alternative I reach for, when a project already runs it, is the 1Password CLI. With op run, we store op://vault/item/field references, and 1Password resolves them into a child process's environment for the life of that process alone. The real values stay out of your project files, and op run redacts them on a best-effort basis when they surface in stdout — redaction, not containment: a child process can still read, transform, or log the resolved value. For an agentic workflow that is the better fit, because the secret stays out of any project file entirely.
Injecting the secret as an environment variable still does not isolate it from the model: on Linux, another process running as our user can usually read our processes' environment, and the model runs as our user. When the model's process must not reach the credential at all, use a PreToolUse hook, or move the credential behind a boundary the model's user cannot cross — a separate OS identity, a broker process, or a container it has no access to.
For the example below we use SecretStore: it is a Microsoft module that runs anywhere PowerShell 7 does and needs no account. It does prompt for a vault password to unlock.
What the context changes
With Context7 connected and the CLAUDE.md in the project root, we hand the model one plain instruction:
Write a PowerShell script that gets all the Secure Infrastructure Account strong accounts for databases. Make it AS SIMPLE AS POSSIBLE. Do not go overboard with multiple functions, parameterization, etc. The simpler the better.
The difference from a cold start shows up in the first draft.
Without the context, a model tends to produce something like this -- a composite of the failure modes I have watched, not one captured run:
# context-free draft -- hardcoded secret, self-hosted flow, hosts collapsed
$secret = 'svc-Pa55w0rd!' # hardcoded
$logon = 'https://acme.cyberark.cloud/PasswordVault/API/auth/CyberArk/Logon' # self-hosted flow
\(token = Invoke-RestMethod -Method Post -Uri \)logon -Body @{ username = 'svc'; password = $secret }
# prove the token against a service -- but on the bare subdomain, not the .dpa service host
Invoke-RestMethod -Uri 'https://acme.cyberark.cloud/dpa/api/...' -Headers @{ Authorization = $token }
It hardcodes the secret, reaches for the PAM self-hosted /PasswordVault logon flow, and collapses the token host and the service host into the one subdomain it was handed. The script fails on the first call, and the error is a DNS or 404 that points at the wrong layer.
With the context, the model has what it needs before it writes a line: the platform-token contract as facts from Context7, the host and secret rules from CLAUDE.md, and the ISP tenant's subdomain and Identity tenant ID as non-secret config in a .env. The first draft already sends the grant to platformtoken on the .id host, proves it against the .dpa host built from the tenant subdomain, and reads the credentials from the vault instead of inventing them.
That draft is the script below. Run that prompt yourself and the exact lines will differ -- the model is non-deterministic, and more than one script is correct here -- but the parts the context pins down come out the same: the two hosts, the platform-token grant, and the secret read from the vault by name.
The same request with the context in place: the model honors the host rules and reads the secret from the vault.
The script
The script gets a token from the Identity platformtoken, then proves it against a service on the tenant-subdomain host. We point the second call at a non-Identity service, Secure Infrastructure Access (SIA), so the call exercises the second host and confirms the model honored the CLAUDE.md host rule instead of reusing the token endpoint's host.
The ISP tenant subdomain and Identity tenant ID are configuration, not secrets, so they go in a plain .env. The tenant is the same on every run, which is why the script takes no parameters -- we write the two values down once and gitignore the file:
# .env -- tenant identifiers, not secrets
IDENTITY_TENANT_ID=abc1234
SUBDOMAIN=acme
The client_id and client_secret stay in the vault, looked up by name; the names are not sensitive either. With the config in .env, the secret in the vault, and the rules and contract already in place, the model has what it needs to write a script that authenticates and reaches both hosts:
#Requires -Modules Microsoft.PowerShell.SecretManagement
# Non-secret config from .env (IDENTITY_TENANT_ID and SUBDOMAIN are different hosts).
Get-Content .env | ForEach-Object {
if (\(_ -match '^\s*([^#=]+?)\s*=\s*(.*)\)') { Set-Item "env:\((\)Matches[1].Trim())" $Matches[2].Trim() }
}
# Service-user (OAuth confidential client) credentials from the vault — never hardcoded.
$clientId = Get-Secret -Name 'idira-client-id' -AsPlainText
$clientSecret = Get-Secret -Name 'idira-client-secret' -AsPlainText
# OAuth2 client_credentials token from the identity tenant. Short-lived; fetched per run.
$token = (Invoke-RestMethod -Method Post `
-Uri "https://$env:IDENTITY_TENANT_ID.id.cyberark.cloud/oauth2/platformtoken" `
-ContentType 'application/x-www-form-urlencoded' `
-Body @{ grant_type = 'client_credentials'; client_id = \(clientId; client_secret = \)clientSecret }
).access_token
# SIA (formerly DPA) database strong accounts. Verify this path against your tenant's
# SIA OpenAPI — api-docs.cyberark.com lists it inconsistently as /databases/strong-accounts.
(Invoke-RestMethod -Method Get `
-Uri "https://$env:SUBDOMAIN.dpa.cyberark.cloud/databases/strong-accounts" `
-Headers @{ Authorization = "Bearer $token" }
).accounts
The two Invoke-RestMethod calls deliberately hit two different hosts, \(env:IDENTITY_TENANT_ID.id.cyberark.cloud and \)env:SUBDOMAIN.dpa.cyberark.cloud, and that split is the point. Collapse the two hosts into one value, and the second call fails the moment we run it.
Get-Secret -AsPlainText does put the secret into a variable in process memory, where it lives until the process exits. That is unavoidable, since the HTTP body needs the cleartext. We never write the secret to a file, never log the secret, never hand the secret to anywhere the model captures output. Keep it that way.
Context7 changes the failure mode; it does not remove the need to verify. Pulling the endpoint from the docs stops the model inventing one from memory, but the docs themselves can be wrong, and we hit exactly that on the strong-accounts call: the model pulled /databases/strong-accounts from Context7 as the rule demands and flagged it right there in the script above, but that path did not match what works against the tenant, which is /api/database-strong-accounts. So whatever the model writes from a retrieved contract gets checked against the live tenant before we trust it. The docs raise the floor; they are not the last word.
Limitations
The script authenticates and proves the two-host split, but it is deliberately minimal, so a few limits come with it:
We add no token caching or refresh here. For a long-running job, re-authenticate on expiry instead of holding a token past
expires_in.A service account that authenticates with a client secret has no MFA in front of it, so treat the service user's permissions as the blast radius and keep them minimal.
Runtime secret injection is not a sandbox; a
PreToolUsehook (or an equivalent boundary outside the harness) is still required to keep the model's process from reaching the credential.SecretStore unlocks with a vault password; for unattended runs you configure that password, which is one more secret to place carefully.
A real integration needs error handling the example leaves out.
Context7 can reproduce errors in the upstream docs, so verify the retrieved contract.
Make it a skill
I built grant-cli, a just-in-time (JIT) cloud-role-elevation tool that wraps the idsec-sdk-golang ISP authenticator, on this exact loop, and the pattern carries well beyond a single language or service.
The CLAUDE.md rules and the Context7 lookups are not specific to that tool or this script. The host rules and the service-user auth note hold on every ISP project we touch, and so does the instruction to pull the contract from Context7. That is the signal that a section of CLAUDE.md has outgrown being a fact and become a procedure worth packaging as a skill: a skill loads only when it is relevant and follows the Agent Skills standard across harnesses, so the tenant knowledge travels into the next tool instead of being retyped. Context7 itself already ships as a skill and the ISP rules can too.



