@seekrit/paperclip-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/dist/manifest.js +2 -0
- package/dist/shared.js +234 -0
- package/dist/ui/index.js +136 -0
- package/dist/worker.js +514 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @seekrit/paperclip-plugin
|
|
2
|
+
|
|
3
|
+
seekrit for [Paperclip](https://docs.paperclip.ing) — end-to-end encrypted
|
|
4
|
+
secrets for the agents you run at work, without a credential ever coming to rest
|
|
5
|
+
in Paperclip.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx paperclipai plugin install @seekrit/paperclip-plugin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it adds
|
|
12
|
+
|
|
13
|
+
| | |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| **4 agent tools** | `seekrit:list_secrets`, `seekrit:run_command`, `seekrit:store_secret`, `seekrit:proxy_config` |
|
|
16
|
+
| **3 skills** | `seekrit-secrets`, `seekrit-agent-keys`, `seekrit-paperclip`, installed into the company's library |
|
|
17
|
+
| **1 page** | A secrets panel at `/:company/seekrit` — names, versions, last changed. No values. |
|
|
18
|
+
|
|
19
|
+
## Configure it
|
|
20
|
+
|
|
21
|
+
One field is required: a **seekrit service token**, picked with Paperclip's
|
|
22
|
+
secret picker in Settings → Plugins → seekrit. Scope it to the environments
|
|
23
|
+
these agents should reach; it is the ceiling on everything the tools can do.
|
|
24
|
+
|
|
25
|
+
The optional fields set defaults so a tool call needs no arguments: `app`,
|
|
26
|
+
`env`, `group`, plus `allowRunCommand` (turn off execution and leave the
|
|
27
|
+
read-only and write-only tools), `cliPath`, and `timeoutSeconds`.
|
|
28
|
+
|
|
29
|
+
The [seekrit CLI](https://www.npmjs.com/package/@seekrit/cli) must be on the
|
|
30
|
+
Paperclip host's `PATH` — the plugin shells out to it rather than reimplementing
|
|
31
|
+
the crypto. `npm i -g @seekrit/cli`, or point `cliPath` at it.
|
|
32
|
+
|
|
33
|
+
## Why a seekrit secret is not a Paperclip `secret_ref`
|
|
34
|
+
|
|
35
|
+
Paperclip's secret provider list is closed — `local_encrypted`,
|
|
36
|
+
`aws_secrets_manager`, `gcp_secret_manager`, `vault` — so there is no seekrit
|
|
37
|
+
provider to select, and this plugin does not pretend otherwise. It does not copy
|
|
38
|
+
values into Paperclip's store either: that would create a second plaintext copy
|
|
39
|
+
under a different lifecycle, where a rotation in seekrit silently leaves
|
|
40
|
+
Paperclip serving the old value and a revocation revokes nothing.
|
|
41
|
+
|
|
42
|
+
Instead the tools keep resolution at the moment of use, two ways:
|
|
43
|
+
|
|
44
|
+
- **`run_command`** injects secrets into one child process and returns its exit
|
|
45
|
+
code and output. The plugin worker never sees a value; `seekrit run` decrypts
|
|
46
|
+
inside the child it execs.
|
|
47
|
+
- **`proxy_config`** generates a [`seekrit-proxy`](https://seekrit.dev/docs/guides/agent-proxy)
|
|
48
|
+
configuration so an untrusted workload sends `{{seekrit:NAME}}` placeholders and
|
|
49
|
+
the proxy — which holds the credentials — swaps them in on the way out, toward
|
|
50
|
+
allowlisted hosts only.
|
|
51
|
+
|
|
52
|
+
Paperclip's database ends up holding exactly one credential: this plugin's own
|
|
53
|
+
service token.
|
|
54
|
+
|
|
55
|
+
## What it deliberately cannot do
|
|
56
|
+
|
|
57
|
+
- **Read a secret value.** There is no `get_secret` tool, and a test enforces
|
|
58
|
+
the absence. Reading a value is the local MCP server's job, next to the key.
|
|
59
|
+
- **Reach the network.** No `http.outbound` capability — the CLI subprocess makes
|
|
60
|
+
the API calls, in its own process.
|
|
61
|
+
- **Post to an issue.** No `issue.comments.create`. Tools return results to the
|
|
62
|
+
agent, which writes its own comment in its own voice.
|
|
63
|
+
- **Redact a command's output.** Because the plugin never sees the injected
|
|
64
|
+
values, it cannot scan for them. A program that prints its own credential has
|
|
65
|
+
leaked it, and the `run_command` description says so to the agent.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm --filter @seekrit/paperclip-plugin test # regenerates skills, then runs vitest
|
|
71
|
+
pnpm --filter @seekrit/paperclip-plugin build # dist/manifest.js, dist/worker.js, dist/ui/
|
|
72
|
+
npx paperclipai plugin install ./apps/paperclip-plugin
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`src/skills.generated.ts` is generated from `agent-plugin/skills/**` by
|
|
76
|
+
`gen-skills.mjs` and gitignored — Paperclip installs managed skills from inline
|
|
77
|
+
content, so the manifest has to carry the bytes. `test/skills.test.ts` re-reads
|
|
78
|
+
the originals, so a stale generated file fails rather than shipping.
|
|
79
|
+
|
|
80
|
+
Tests run the real worker against a **fake `seekrit` binary** that records how it
|
|
81
|
+
was called, which is what pins the properties that matter: a stored value travels
|
|
82
|
+
on stdin and never in argv, and the service token reaches the child as an
|
|
83
|
+
environment variable and appears in no activity row, log line, or error message.
|
|
84
|
+
|
|
85
|
+
MIT licensed. Docs: <https://seekrit.dev/docs/guides/ai-agents/paperclip>
|
package/dist/manifest.js
ADDED
package/dist/shared.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
//#region package.json
|
|
2
|
+
var version = "0.1.0";
|
|
3
|
+
//#endregion
|
|
4
|
+
//#region src/skills.generated.ts
|
|
5
|
+
const MANAGED_SKILLS = [
|
|
6
|
+
{
|
|
7
|
+
"skillKey": "seekrit-secrets",
|
|
8
|
+
"displayName": "seekrit secrets",
|
|
9
|
+
"slug": "seekrit-secrets",
|
|
10
|
+
"description": "Store and use end-to-end encrypted secrets with seekrit instead of putting values in .env files, shell commands, or source. Use whenever a task involves an API key, token, password, connection string, or .env file — storing a credential a human just handed over, running a command that needs one, wiring up an app's configuration, or when a value is missing and the temptation is to hardcode it or go hunting for it. Also covers setting seekrit up from nothing (signup, org, app, environment) and granting an environment to a service token.",
|
|
11
|
+
"markdown": "---\nname: seekrit-secrets\ndescription: Store and use end-to-end encrypted secrets with seekrit instead of putting values in .env files, shell commands, or source. Use whenever a task involves an API key, token, password, connection string, or .env file — storing a credential a human just handed over, running a command that needs one, wiring up an app's configuration, or when a value is missing and the temptation is to hardcode it or go hunting for it. Also covers setting seekrit up from nothing (signup, org, app, environment) and granting an environment to a service token.\nlicense: MIT\ncompatibility: Needs network access to seekrit's API. The local server runs through npx (Node 20+); the hosted server needs no install.\n---\n\n# Secrets with seekrit\n\nseekrit is an end-to-end encrypted secrets manager: the server stores only\nciphertext, and values are decrypted in *this* process, next to the credential.\nThat single fact produces one hard rule and one preferred verb.\n\n## The rule\n\n**A secret value must not come to rest anywhere it can be read again.**\n\n- Never write a value into `.env`, a config file, a Dockerfile, a Kubernetes\n manifest, or source — even temporarily, even in a file you plan to delete.\n- Never put a value in a command line (`export KEY=…`, `curl -H \"Authorization:\n Bearer sk-…\"`). It lands in shell history, in process listings, and in this\n transcript.\n- Never print a value to show your work, and never quote one back to the user\n who pasted it.\n- Never route around a missing value. If a `.env` read is denied or a file is\n absent, do **not** try `docker compose config`, `git log -p`, a lockfile, a CI\n log, a cloud console, or another secrets CLI to reconstruct it. Ask, or use the\n tools below.\n\nIf you have already broken this rule in this session — a value in a file, a\ncommand, or your output — say so plainly and treat that credential as needing\nrotation.\n\n## The preferred verb: run, don't read\n\nAlmost every task that \"needs a secret\" actually needs a *process* that has the\nsecret. Inject it and never see it:\n\n| Where you are | Do this |\n| --- | --- |\n| MCP client | `run_command { command: \"pnpm\", args: [\"dev\"] }` |\n| A shell | `seekrit run -- pnpm dev` |\n| Dockerfile / CI / container | `seekrit-run -- ./server` (static binary) |\n| Kubernetes | the seekrit ESO chart, not a copied Secret |\n\n`run_command` resolves the environment, injects every granted secret as an\nenvironment variable in the child process only, and returns the exit code and\ncaptured output. **It never returns values.** Precedence, highest first: process\nenv → `.env` overlay → app environment → composed groups.\n\nRead a value with `get_secret { name, reveal: true }` only when a human\nexplicitly asked to see it, or when it must be pasted into a system with no\nother way in. Say out loud that you are doing it, and prefer\n`get_secret { name }` (which confirms existence without the value) for checks.\n\n`export_env` writes a resolved `.env`. It exists for runtimes that genuinely\ncannot be wrapped. Treat it as the last resort it is, and never as a\nconvenience.\n\n## Decision table\n\n| Situation | Tool |\n| --- | --- |\n| Run something that needs secrets | `run_command` / `seekrit run` |\n| A human pasted a new credential | `set_secret { name, value }` — then don't echo it |\n| Rotate a value | `set_secret` again; history is kept, `list_secret_versions` shows it |\n| \"What config does this app have?\" | `list_secrets` — names and metadata, never values |\n| \"Is `STRIPE_KEY` set?\" | `list_secrets`, or `get_secret { name }` without `reveal` |\n| Wire a new app or service | `create_app` → `create_env` → `set_secret` |\n| Give a deployment read access | `create_token` then `grant_env` |\n| Share config across apps | groups — see [references/model.md](references/model.md) |\n| Try a risky config change | `create_branch` on the environment, then `run_command { branch }` |\n| Needs a *third-party API key it must not read* | the **seekrit-agent-keys** skill |\n\n## Storing a value you were handed\n\n```\nset_secret { app: \"storefront\", env: \"production\", name: \"STRIPE_SECRET_KEY\", value: \"<the value>\" }\n```\n\nThen confirm by name only: \"stored `STRIPE_SECRET_KEY` in storefront/production.\"\nA value can reference another with `${OTHER_SECRET}`, so build connection\nstrings from parts rather than duplicating a password.\n\nImporting an existing `.env` is one call — `seekrit secrets import .env` — after\nwhich **delete the file** and tell the user you did.\n\n## If you have no credential yet\n\nThe hosted server needs no account to connect. Call `signup` on it:\n\n```\nsignup { orgName: \"Acme Storefront\", orgSlug: \"acme-storefront\" }\n→ { credential: { clientId, clientSecret } }\n```\n\nName the org after the real project or company — a human claims it by that name\nlater, so `test` makes it unmanageable. The `clientSecret` is shown once.\nPersist it so both servers keep working:\n\n```bash\nseekrit login --client-id <id> --client-secret <secret>\n```\n\nThat writes `~/.config/seekrit/config.json`, which the local server reads with\nno environment plumbing. (`SEEKRIT_CLIENT_ID` / `SEEKRIT_CLIENT_SECRET` in the\nenvironment work too, if the client passes its environment through.) Machine\ncredentials auto-mint an admin token, so one credential drives both servers.\n\n## Two servers, one credential\n\nThis plugin installs both. They are split along the encryption boundary, and\nthe split is enforced, not conventional:\n\n- **`seekrit`** (local, stdio) — anything touching a *value*: `set_secret`,\n `get_secret`, `run_command`, `export_env`, `create_env`, `create_token`,\n `grant_env`, KMS operations, database leases. Decryption happens here because\n this is where the key is.\n- **`seekrit-cloud`** (hosted, `mcp.seekrit.dev`) — metadata and management with\n no install: orgs, apps, environments, groups, composition, members, audit,\n billing, secret *names*. It cannot decrypt anything and will refuse a service\n token outright.\n\nStart on the local server; it can finish a secrets task end to end. Use the\nhosted one when you cannot install anything, or for management and audit. If you\nare on the hosted server and hit a wall, `local_tool_for { operation }` names the\nlocal tool to use instead.\n\n## Further reading\n\n- [references/model.md](references/model.md) — orgs, apps, environments, groups,\n composition, branches, references, and how to model a tenant.\n- [references/cli.md](references/cli.md) — the CLI equivalent of every tool\n here, for shells, CI, and containers.\n",
|
|
12
|
+
"files": [{
|
|
13
|
+
"path": "references/cli.md",
|
|
14
|
+
"content": "# CLI equivalents\n\nInstall: `npm install -g @seekrit/cli` (or `npx @seekrit/cli`). For containers\nand CI there is `seekrit-run`, a static single binary with the same injection\nbehaviour and no Node dependency.\n\n## Authenticate\n\n```bash\nseekrit login --client-id <id> --client-secret <secret> # machine credential\nseekrit login --token skt_… # existing service token\nseekrit login # browser approval, for a human\nseekrit whoami\n```\n\nCredentials land in `~/.config/seekrit/config.json`. `SEEKRIT_TOKEN`,\n`SEEKRIT_CLIENT_ID` / `SEEKRIT_CLIENT_SECRET`, and `SEEKRIT_API_URL` override it.\n\n## Run something with secrets injected\n\n```bash\nseekrit run -- pnpm dev\nseekrit run --env staging -- ./migrate\nseekrit run --branch pr-1234 -- pnpm test\nseekrit run --with platform=sandbox -- ./server # override one group's slice\nseekrit run --explain -- true # where each variable came from\n```\n\n`--explain` prints resolution provenance to stderr and never prints values. It\nis the right way to debug \"why is this variable wrong\".\n\n## Secrets\n\n```bash\nseekrit secrets list # names and metadata only\nseekrit secrets set STRIPE_KEY # reads the value from stdin\nseekrit secrets set TLS_KEY --file key.pem # from a file, no shell quoting\nseekrit secrets get NAME # prints the value — think first\nseekrit secrets import .env # then delete the file\nseekrit secrets history NAME\nseekrit secrets restore NAME 3\nseekrit secrets rm NAME\n```\n\nOmit the value argument so it arrives on stdin, or use `--file` for a PEM or\nJSON credential. Passing the value inline puts it in shell history.\n\n## Structure\n\n```bash\nseekrit init # writes seekrit.json for this project\nseekrit app create / list / show\nseekrit env create / list / groups\nseekrit group create / env create # shared secret bags and their slices\nseekrit branch create <slug> --from production --ttl 7d\nseekrit token create / list / revoke\nseekrit grant --token skt_… --app storefront --env production\nseekrit audit\n```\n\n## In CI and containers\n\nGive the job a token scoped to one environment, then wrap the command:\n\n```bash\nseekrit-run -- ./deploy.sh\n```\n\nNothing is written to disk, and the child process is the only thing that ever\nholds the values. For GitHub Actions there is a dedicated action; for Kubernetes\nuse the seekrit External Secrets Operator chart rather than copying values into\na `Secret` by hand.\n"
|
|
15
|
+
}, {
|
|
16
|
+
"path": "references/model.md",
|
|
17
|
+
"content": "# The resource model\n\n```\norganization\n└── application (a codebase / service)\n └── environment (production, staging, dev …)\n └── secret (name → encrypted value, versioned)\ngroups (org-scoped reusable secret bags, also per-environment)\n```\n\nAn application environment resolves to **its own secrets plus the groups it\ncomposes**. Values layer, lowest precedence first:\n\n```\ngroup secrets < app-env secrets < .env overlay < process env\n```\n\nAfter merging, `${OTHER_SECRET}` references inside values are expanded — so a\n`DATABASE_URL` can be assembled from a group's host and the app's password\nwithout duplicating either.\n\n## Groups\n\nUse a group when two or more apps need the same value (a shared database, an\nobservability key, an internal CA). A group has its own environments keyed by\nslug; at resolve time each composed group is matched to the environment whose\nslug equals the app environment's, so composing `platform` into\n`storefront/production` pulls `platform/production`.\n\n- `create_group` / `create_group_env` / `compose_group` / `uncompose_group`\n- `list_env_groups` shows what an environment pulls in; `list_group_envs` shows\n a group's slices.\n\nPrefer a group over copying a value into five apps. Copies drift and rotate\nbadly.\n\n## Branches\n\nA branch is an ephemeral fork of an environment: it inherits everything and\noverrides only what differs, then deletes itself. Use one for a preview deploy,\na pull request, or a risky config experiment — never a real environment named\n`pr-1234`.\n\n- `create_branch { app, from: \"production\", slug: \"pr-142\" }`, then `run_command { branch }`\n- `delete_branch` when done; `list_branches` to find strays.\n\n## Service tokens and grants\n\nA token is a principal with a keypair, not a password. Creating one does not\ngrant it anything; `grant_env` wraps that environment's data key to the token's\npublic key, which is what lets it decrypt.\n\n- `create_token` (add `admin: true` only for a management credential)\n- `grant_env { token, app, env }` — one grant per environment the token reads\n- `revoke_token` immediately; `list_tokens` to audit\n\nA deployment should hold a token scoped to exactly one environment. An admin\ntoken belongs on a developer machine or a management job, never in a running\nservice.\n\n## Temporary access (leases)\n\nFor a human or agent that needs a database *now* and should not keep it, mint a\nlease instead of handing over a stored credential: `create_pg_lease` /\n`create_mysql_lease` provision a scoped, expiring database user through a\ncustomer-hosted provisioner, and `revoke_pg_lease` / `revoke_mysql_lease` end it\nearly. `list_pg_targets` / `list_mysql_targets` show what can be leased.\n\nPrefer a lease over `get_secret reveal:true` on a database password. It expires\non its own, and the audit trail says who had it and when.\n\n## Modelling a multi-tenant agent\n\nIf one agent serves many customers and each needs its own credentials, pick by\nhow the tenants differ:\n\n| Shape | Model it as | Why |\n| --- | --- | --- |\n| Tenants share the code, differ in credentials | one **environment per tenant** under one app | independent grants, independent audit, independent rotation |\n| Most config is shared, a few keys differ | one group composed by every tenant environment | rotate the shared parts once |\n| Access should expire after a task | a **lease** | nothing to revoke later |\n| Tenant credential must never be readable by the agent | the **seekrit-agent-keys** skill (proxy) | the agent holds a placeholder, not a key |\n\nResolve per run, not per process: fetch for the tenant you are serving now and\nlet it go afterwards. Do not build a process-wide map of every tenant's secrets\n— that turns one compromised request into a full breach.\n"
|
|
18
|
+
}]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"skillKey": "seekrit-agent-keys",
|
|
22
|
+
"displayName": "seekrit agent keys",
|
|
23
|
+
"slug": "seekrit-agent-keys",
|
|
24
|
+
"description": "Let an agent, sandbox, or untrusted process call an API without ever being able to read the credential, using seekrit's egress proxy and {{seekrit:NAME}} placeholders behind a default-deny allowlist. Use when running model-generated or untrusted code that needs a third-party key (LLM providers, Stripe, GitHub, internal APIs), when a sandbox or container needs credentials, when a key must be usable for one operation but not others, or when asked to limit what an agent can do with a key rather than only where the key is stored.",
|
|
25
|
+
"markdown": "---\nname: seekrit-agent-keys\ndescription: Let an agent, sandbox, or untrusted process call an API without ever being able to read the credential, using seekrit's egress proxy and {{seekrit:NAME}} placeholders behind a default-deny allowlist. Use when running model-generated or untrusted code that needs a third-party key (LLM providers, Stripe, GitHub, internal APIs), when a sandbox or container needs credentials, when a key must be usable for one operation but not others, or when asked to limit what an agent can do with a key rather than only where the key is stored.\nlicense: MIT\ncompatibility: Needs Docker (or the seekrit-proxy binary) and network access. The proxy runs beside the workload, not inside seekrit.\n---\n\n# Keys an agent cannot read\n\nInjecting a secret as an environment variable (see the **seekrit-secrets**\nskill) is right when you trust the process. It is the wrong tool when the\nprocess is a sandbox, model-generated code, or an agent whose output you cannot\npredict: anything in the environment can be printed, logged, or POSTed\nsomewhere else.\n\nThe seekrit **egress proxy** removes the key from the workload entirely. The\nworkload sends a placeholder; the proxy — which holds the token — swaps in the\nreal value on the way out, and only toward destinations you allowlisted.\n\n```\nagent ──\"Authorization: Bearer {{seekrit:OPENAI_API_KEY}}\"──▶ proxy ──real key──▶ api.openai.com\n │\n └─ anything not allowlisted: refused\n```\n\nA compromised or merely creative agent cannot exfiltrate the key, because it\nnever had it, and cannot redirect it, because the destination is fixed by\nconfig the agent does not control.\n\n## Start it\n\nThe proxy is a single static binary on `scratch`; the container is the fastest\npath. Write a config, then run it beside the workload:\n\n```toml\n# seekrit-proxy.toml\nlisten = \"127.0.0.1:8080\"\n\n[[route]]\nprefix = \"/openai\"\nupstream = \"https://api.openai.com\"\nallow = [\"OPENAI_API_KEY\"]\nmethods = [\"POST\"]\npaths = [\"/v1/chat/completions\", \"/v1/embeddings\"]\n```\n\n```bash\ndocker run --rm -e SEEKRIT_TOKEN=skt_… \\\n -v \"$PWD/seekrit-proxy.toml:/seekrit-proxy.toml\" \\\n -p 8080:8080 seekritdev/proxy --listen 0.0.0.0:8080\n```\n\nThen point the workload's client at the proxy and give it a placeholder instead\nof a key:\n\n```ts\nconst openai = new OpenAI({\n baseURL: \"http://127.0.0.1:8080/openai/v1\",\n apiKey: \"{{seekrit:OPENAI_API_KEY}}\",\n});\n```\n\nAny OpenAI-compatible endpoint works the same way — LiteLLM, OpenRouter, vLLM,\na self-hosted gateway. So does Anthropic, Stripe, or an internal service; only\nthe `upstream` and header change.\n\n## The allowlist is the security boundary\n\nBoth modes are **default-deny**. A rule grants three things, and each one is\nworth setting deliberately:\n\n- `allow` — which secrets may be substituted toward this upstream. A key\n allowlisted for `api.openai.com` cannot leak toward `api.attacker.com`.\n- `methods` — which HTTP methods. Omitted means any.\n- `paths` — which paths, matched after the prefix is stripped (`*` within a\n segment, `**` across them). Omitted means any.\n\n`methods` and `paths` are what turn the proxy from anti-theft into anti-misuse:\nan agent with a legitimate Stripe key still cannot reach `DELETE /v1/customers`\nif you did not grant it.\n\n## Two modes\n\n**Reverse proxy** (`[[route]]`, above) — the workload points its base URL at the\nproxy. No certificates, works with any HTTP client, but every client needs its\nbase URL changed.\n\n**Forward proxy** (`[forward]`) — the workload sets\n`HTTPS_PROXY=http://127.0.0.1:8081` and trusts the proxy's CA. The proxy\nintercepts TLS for ruled hosts, substitutes, and re-originates. Transparent to\ncode you cannot modify, which makes it the right mode for a sandbox running\narbitrary generated code. Hosts with no rule are tunnelled untouched by default;\nset `unmatched_host_policy = \"deny\"` for a hard egress boundary.\n\n## Sandboxes: never bake a key into the image\n\nFor E2B, Modal, Daytona, Vercel Sandbox, Cloudflare Sandbox/Containers, or a\nplain container: **do not** resolve secrets and pass them as sandbox environment\nvariables. Run the proxy outside the sandbox, give the sandbox only the proxy\naddress and placeholders, and set `HTTPS_PROXY` plus the CA in its environment.\nThe sandbox can then use the credential and still holds nothing worth stealing —\nand its egress is bounded by the same allowlist.\n\n## Many agents, one proxy\n\nWhen one proxy fronts several agents that should not have equal reach, the\ntrusted orchestrator mints a **session ticket** (`POST /session` on the\n`[control]` listener, naming an agent identity and optionally a narrower secret\nset) and hands the agent the opaque ticket, which it presents as\n`x-seekrit-ticket`. Scopes can only narrow: the effective set is the ticket's\nintersected with the published policy. The control token must be unreadable by\nthe agents, or any of them could mint a ticket for any identity.\n\n## Policy from the dashboard, not the file\n\n`[policy] source = \"server\"` takes the rules from agent access policy in the\nseekrit dashboard, so adding an upstream is a UI change instead of a redeploy.\nThe bundle is signed in the publishing admin's browser and this proxy refuses\nany bundle not signed by a thumbprint pinned in its own local file — seekrit can\nwithhold policy (the proxy fails closed) but cannot widen it. Keep the `signers`\nlist in the file, pin at least two admins, and commit it.\n\n## When not to use this\n\n- The process is trusted and you control it → inject env vars instead\n (**seekrit-secrets**). Simpler, no proxy to run.\n- The credential is needed by a library that signs requests locally (AWS SigV4,\n some database drivers) → substitution happens on the wire, so a locally\n computed signature over a placeholder will not verify. Use a scoped token or a\n database lease instead.\n- You want per-request model routing, cost tracking, or fallbacks → that is a\n model gateway's job. Put the gateway behind the proxy and keep its key in\n seekrit.\n\n## Further reading\n\n- [references/proxy-config.md](references/proxy-config.md) — annotated config\n for both modes, refresh intervals, and the fleet ceiling.\n- <https://seekrit.dev/docs/guides/frameworks> — the exact base-URL and API-key\n argument to change in LangGraph, Mastra, Pydantic AI, the AI SDK, CrewAI,\n LlamaIndex, and both Agents SDKs.\n",
|
|
26
|
+
"files": [{
|
|
27
|
+
"path": "references/proxy-config.md",
|
|
28
|
+
"content": "# Proxy configuration reference\n\nThe token comes from the environment (`SEEKRIT_TOKEN`) — **never** from this\nfile. The container reads its config from `/seekrit-proxy.toml`; override with\n`--config`. Bind to loopback unless the proxy is reached across a network\nnamespace, in which case `--listen 0.0.0.0:8080` and let the network boundary do\nthe work.\n\n## Reverse proxy — one route per upstream\n\n```toml\nlisten = \"127.0.0.1:8080\"\n\n# Request bodies are buffered to substitute placeholders, so they are capped.\n# max_request_body_bytes = 2097152 # 2 MiB default\n\n[[route]]\nprefix = \"/openai\"\nupstream = \"https://api.openai.com\"\nallow = [\"OPENAI_API_KEY\"]\nmethods = [\"POST\"]\npaths = [\"/v1/chat/completions\", \"/v1/embeddings\"]\nlabel = \"chat + embeddings\" # shown in refusal logs\n\n[[route]]\nprefix = \"/anthropic\"\nupstream = \"https://api.anthropic.com\"\nallow = [\"ANTHROPIC_API_KEY\"]\nmethods = [\"POST\"]\npaths = [\"/v1/messages\"]\n\n[[route]]\nprefix = \"/stripe\"\nupstream = \"https://api.stripe.com\"\nallow = [\"STRIPE_SECRET_KEY\"]\nmethods = [\"GET\", \"POST\"]\npaths = [\"/v1/customers\", \"/v1/customers/*\", \"/v1/charges\"]\n```\n\nThe workload calls `http://127.0.0.1:8080/openai/v1/chat/completions` with\n`Authorization: Bearer {{seekrit:OPENAI_API_KEY}}`. Paths are matched *after*\nthe prefix is stripped.\n\n## Forward proxy — for code you cannot modify\n\n```toml\n[forward]\nlisten = \"127.0.0.1:8081\"\n\n# Hosts with no rule:\n# \"tunnel\" (default) — passed through untouched, no interception\n# \"deny\" — refused, so only ruled hosts are reachable at all\nunmatched_host_policy = \"deny\"\n\n# Generated and persisted on first run if absent.\nca_cert = \"seekrit-proxy-ca.pem\"\nca_key = \"seekrit-proxy-ca-key.pem\"\n\n[[forward.host]]\nmatch = \"api.openai.com\" # bare hostname: no scheme, port, or path\nallow = [\"OPENAI_API_KEY\"]\nmethods = [\"POST\"]\npaths = [\"/v1/**\"]\n\n# Several rules may name one host; first match wins, so narrow goes above broad.\n[[forward.host]]\nmatch = \"api.openai.com\"\nmethods = [\"GET\"] # reads allowed, but carry no credential\n```\n\nThe workload needs `HTTPS_PROXY=http://127.0.0.1:8081` and the CA in its trust\nstore — `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, or the\nsystem store, depending on the runtime.\n\n## Rules from the dashboard\n\n```toml\n[policy]\nsource = \"server\"\nagent = \"nova\" # this deployment's agent identity\nrefresh_interval = \"10s\"\n\n# The trust anchor — the one thing that must not come from the server. Copy it\n# from the dashboard's trust-anchor panel and commit it. Pin a second admin too,\n# or one lost passphrase means nobody can publish.\nsigners = [\"kNc8…thumbprint\"]\n\n# Optional fleet ceiling: server policy may only narrow this. Inappropriate for\n# interactive development, where the local agent can edit local files anyway.\n[[policy.ceiling]]\nhost = \"api.openai.com\"\nallow = [\"OPENAI_API_KEY\"]\n```\n\nIn server mode, `allow` / `methods` / `paths` in the file are **rejected**\nrather than ignored, and secrets are re-resolved on the same interval so a new\nrule and the credential it names arrive together.\n\n## Session tickets (one proxy, several agents)\n\n```toml\n[control]\nlisten = \"127.0.0.1:9090\"\nttl = \"1h\"\nmax_ttl = \"12h\"\n```\n\nRequires `SEEKRIT_PROXY_CONTROL_TOKEN` in the environment, and that token must\nnot be readable by the agents. The orchestrator `POST`s `/session` to mint a\nticket; the agent presents it as `x-seekrit-ticket`.\n\n## Picking up newly added secrets\n\n```toml\n[secrets]\nrefresh_interval = \"30s\"\n```\n\nWithout this (in file-policy mode) the proxy resolves once at startup, so a\nsecret added later never reaches a healthy running proxy. Server-policy mode\nturns refresh on implicitly. No new grant is involved — a new secret in an\nalready-granted environment decrypts with the key the proxy already holds.\n\n## Telemetry\n\nTraces, metrics, and logs go to **your** OTLP collector, configured with the\nstandard `OTEL_*` variables, and are inert unless\n`OTEL_EXPORTER_OTLP_ENDPOINT` is set. Secret names, hosts, durations, status\ncodes, and refusal reasons are recorded; values, bodies, and `Authorization`\nheaders never are. `propagate_trace_upstream = true` additionally sends W3C\ntrace headers to the upstream — off by default, since most upstreams are third\nparties.\n"
|
|
29
|
+
}]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"skillKey": "seekrit-paperclip",
|
|
33
|
+
"displayName": "seekrit in Paperclip",
|
|
34
|
+
"slug": "seekrit-paperclip",
|
|
35
|
+
"description": "Handle credentials correctly while working inside Paperclip, the AI agent management platform. Use when a Paperclip run needs an API key, when you are about to put a value in an adapter env map, a project env map, an approval payload, or an issue comment, when a human hands you a credential in a Paperclip issue, or when asked to wire a Paperclip agent up to a third-party API. Also covers why a seekrit secret cannot be a Paperclip secret_ref, and which of the two paths — inject-and-exec or the egress proxy — a given run should use.",
|
|
36
|
+
"markdown": "---\nname: seekrit-paperclip\ndescription: Handle credentials correctly while working inside Paperclip, the AI agent management platform. Use when a Paperclip run needs an API key, when you are about to put a value in an adapter env map, a project env map, an approval payload, or an issue comment, when a human hands you a credential in a Paperclip issue, or when asked to wire a Paperclip agent up to a third-party API. Also covers why a seekrit secret cannot be a Paperclip secret_ref, and which of the two paths — inject-and-exec or the egress proxy — a given run should use.\nlicense: MIT\ncompatibility: Needs the seekrit CLI (or the seekrit Paperclip plugin) on the host running the agent, plus network access to seekrit's API.\n---\n\n# Secrets inside Paperclip\n\nYou are running as a Paperclip agent. Paperclip has its own secret store, and\nseekrit is not one of its providers — the provider list is closed\n(`local_encrypted`, `aws_secrets_manager`, `gcp_secret_manager`, `vault`). So\n**a seekrit secret can never be a Paperclip `secret_ref`.** Do not try to make\none; do not ask an operator to paste a seekrit value into Paperclip's secret\nstore to work around it. That would create a second plaintext copy of a\ncredential seekrit exists to avoid copying.\n\nValues reach a run one of two other ways, and picking between them is the whole\njob.\n\n## The two paths\n\n| Path | Use it when | What the run sees |\n| --- | --- | --- |\n| **Inject and exec** — `seekrit:run_command`, or `seekrit run -- …` in a terminal | You are running a build, a test suite, a migration, a dev server — a process *you* wrote the command for | The real value, in that child process's environment only |\n| **Egress proxy** — `{{seekrit:NAME}}` placeholders behind an allowlist | The thing holding the credential is a model, a sandbox, generated code, or a subagent whose output you cannot predict | Never the value — only a placeholder the proxy swaps on the way out |\n\nDefault to the first for your own tooling and the second for anything you are\nabout to hand to another agent or to code you just generated. When both would\nwork, prefer the proxy: it also bounds *where* the credential can go, which\nPaperclip's execution policy does not — that policy governs review and approval\nstages, not network egress.\n\n## The rule, in Paperclip's terms\n\n**A value must not come to rest anywhere it can be read again.** In this\nenvironment that means, specifically, never write a credential into:\n\n- an agent's **adapter env** map, or a project or routine **env** map — these\n persist in Paperclip's database and render in its UI. (Under\n `PAPERCLIP_SECRETS_STRICT_MODE` the server rejects it outright; assume strict\n mode even where it is off.)\n- an **approval payload**, an **issue comment**, an **issue document**, or a run\n transcript. A comment is the most common accident: it feels like talking to a\n person, and it is a durable row.\n- a **company export**, a workspace file, `.env`, or a commit.\n\nA placeholder is not a value. `{{seekrit:OPENAI_API_KEY}}` is safe to put in an\nadapter env map, in a config file, and in a commit — that is the point of it.\n\nIf you have already broken this rule in this run, say so plainly in your comment\non the issue and treat the credential as needing rotation. Do not quietly move\non.\n\n## When a human hands you a credential\n\nSomeone will paste a key into an issue comment. When that happens:\n\n1. Store it in seekrit immediately — `seekrit:store_secret`, or\n `set_secret { app, env, name, value }` on the local MCP server.\n2. Reply naming the secret, never the value: \"stored `STRIPE_SECRET_KEY` in\n storefront/production.\"\n3. Tell them the comment still holds the plaintext and should be edited or\n deleted, and that the credential is worth rotating if the issue is visible\n beyond the two of you.\n\nNever echo it back to confirm you received it.\n\n## When a value is missing\n\nDo not go looking. A denied read is not a puzzle to solve: reconstructing a\ncredential from `docker compose config`, `git log -p`, a lockfile, a CI log, or\nanother secrets CLI is the failure mode this skill exists to prevent, and it\nlooks like diligence in a transcript.\n\nInstead, in order:\n\n1. `seekrit:list_secrets` — confirm whether the name exists in the environment\n you are pointed at, and whether you are pointed at the right one.\n2. If it genuinely is not there, ask on the issue. Name what you need and what\n it is for.\n3. If the operator would rather grant than paste, point them at\n `seekrit access grant` for a service token, not at Paperclip's secret store.\n\nPaperclip also has its own **secret proposal** flow — agents propose, people\ndecide, and a proposal expires after 14 days. Use it for credentials that\ngenuinely belong to Paperclip (a connector's own token). Do not use it to\nsmuggle a seekrit value into Paperclip config.\n\n## Wiring an agent to a third-party API\n\nThe proxy path, end to end, is in\n[references/paperclip-wiring.md](references/paperclip-wiring.md): the adapter\nenv map to paste, the `seekrit-proxy.toml` to generate, and the two lines that\nmake the runtime trust the proxy's CA. The short version is that the agent's\nadapter env holds placeholders and an `HTTPS_PROXY`, and the real keys stay in\nthe proxy — so a compromised or merely creative run cannot exfiltrate them,\nbecause it never had them.\n\n## Tools\n\nIf the seekrit Paperclip plugin is installed, these are available in a run:\n\n| Tool | Does |\n| --- | --- |\n| `seekrit:list_secrets` | Names and metadata for an environment. Never values. |\n| `seekrit:run_command` | Runs a command with secrets injected into the child only; returns exit code and output. |\n| `seekrit:store_secret` | Stores a value. Write-only — there is no tool that reads one back. |\n| `seekrit:write_proxy_config` | Writes a `seekrit-proxy.toml` and prints the env the workload needs. |\n\nIf it is not installed, everything here is also a CLI invocation — see the\n**seekrit-secrets** skill's CLI reference. If neither is available, say so on the\nissue rather than falling back to a plaintext workaround.\n\n## Further reading\n\n- [references/paperclip-wiring.md](references/paperclip-wiring.md) — adapter env\n maps, proxy config, plugin install, and the MCP wiring per adapter.\n- The **seekrit-secrets** skill — the resource model, bootstrapping from\n nothing, and the full tool surface.\n- The **seekrit-agent-keys** skill — the proxy in depth, including forward mode\n and operation-level allowlists.\n",
|
|
37
|
+
"files": [{
|
|
38
|
+
"path": "references/paperclip-wiring.md",
|
|
39
|
+
"content": "# Wiring seekrit into Paperclip\n\nEverything here is operator work, done once per company or once per agent. If you\nare an agent reading this mid-run, the useful parts are the **adapter env** table\n(what a correctly wired agent's env looks like, so you can tell whether yours is)\nand **Why not a secret_ref** at the bottom.\n\n## 1. The plugin\n\n```bash\nnpx paperclipai plugin install @seekrit/paperclip-plugin\n```\n\nIt contributes four agent tools (`seekrit:list_secrets`, `seekrit:run_command`,\n`seekrit:store_secret`, `seekrit:write_proxy_config`), installs the seekrit\nskills into the company's skills library, and adds a Secrets panel under Company\nSettings that lists what each environment holds — names, versions, and when they\nlast changed, never values.\n\nIts one piece of configuration is a **seekrit service token**, bound as a\nPaperclip `secret_ref` in the plugin's settings form. That is the correct use of\nPaperclip's secret store: the token is Paperclip's own credential, scoped to the\nenvironments you want its agents to reach, and revocable without touching\nanything else.\n\n## 2. The skills\n\nPaperclip installs skills from a GitHub URL and pins them to a commit. On the\nSkills page, paste:\n\n```\nhttps://github.com/seekritdev/agent-plugin\n```\n\nThat picks up `seekrit-secrets`, `seekrit-agent-keys`, and `seekrit-paperclip`.\nAssign them per agent, or company-wide. The plugin installs the same three, so\ndo this only if you are not installing the plugin.\n\n## 3. MCP servers, per adapter\n\nPaperclip is the control plane; the **adapter's runtime** is what speaks MCP. So\nMCP config is per-runtime and lives next to the agent's working directory, not in\nPaperclip's database.\n\nFor the Claude Code adapter (and the Codex, Cursor, Gemini CLI, and OpenCode\nadapters, which inherit their CLI's own MCP support the same way), the file is a\nproject-scoped `.mcp.json` in the agent's working directory:\n\n```bash\nseekrit paperclip init --dir /srv/paperclip/workspaces/storefront\n```\n\nThat writes both seekrit servers into `.mcp.json`, merging with any servers\nalready there:\n\n```json\n{\n \"mcpServers\": {\n \"seekrit\": { \"type\": \"stdio\", \"command\": \"npx\", \"args\": [\"-y\", \"@seekrit/mcp\"] },\n \"seekrit-cloud\": { \"type\": \"streamable-http\", \"url\": \"https://mcp.seekrit.dev/mcp\" }\n }\n}\n```\n\nProject scope is the right one here: an agent's working directory already pins\nit, and two agents pointed at the same repo should see the same servers. For a\nserver one agent alone should see, give that agent its own working directory.\n\n## 4. The proxy, for runs you do not trust\n\n`seekrit paperclip init --proxy --preset anthropic --preset openai --preset gemini`\nwrites a `seekrit-proxy.toml` and prints the adapter env to paste. Run the proxy\nbeside Paperclip — same host, or a sidecar container — with a service token in\n`SEEKRIT_TOKEN`.\n\nForward mode is usually right for Paperclip, because the runtimes are CLIs you\nare not configuring base URLs for:\n\n```toml\nlisten = \"127.0.0.1:8080\"\n\n[forward]\nlisten = \"127.0.0.1:8081\"\nca_cert = \"seekrit-proxy-ca.pem\"\n\n[[rule]]\nhost = \"api.anthropic.com\"\nallow = [\"ANTHROPIC_API_KEY\"]\nmethods = [\"GET\", \"POST\"]\npaths = [\"/v1/**\"]\n```\n\n### Adapter env\n\nPaste this into the agent's **Environment variables** on its Configuration tab.\nEvery value is a plain string — a placeholder is not a secret, so none of these\nneeds a `secret_ref` and none trips strict mode:\n\n| Variable | Value |\n| --- | --- |\n| `ANTHROPIC_API_KEY` | `{{seekrit:ANTHROPIC_API_KEY}}` |\n| `OPENAI_API_KEY` | `{{seekrit:OPENAI_API_KEY}}` |\n| `GEMINI_API_KEY` | `{{seekrit:GEMINI_API_KEY}}` |\n| `HTTPS_PROXY` | `http://127.0.0.1:8081` |\n| `NODE_EXTRA_CA_CERTS` | absolute path to `seekrit-proxy-ca.pem` |\n\nUse `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` instead of `NODE_EXTRA_CA_CERTS` for\na non-Node runtime; a Python adapter needs the latter.\n\nA run wired this way holds no key. The proxy refuses any host not in the file,\nand the file is not something the run can edit.\n\n### Checking it worked\n\nFrom the agent's working directory, ask the runtime to make one real call. A\n`401` from the upstream means the placeholder went out unsubstituted — the\nusual cause is a secret name in the env that is not in the rule's `allow` list.\nA connection error to the upstream means `HTTPS_PROXY` is not reaching the\nproxy. A TLS error means the CA env var is wrong for that runtime.\n\n## 5. Injecting instead, for runs you do trust\n\nWhere the run is your own tooling, skip the proxy and inject:\n\n```\nseekrit:run_command { command: \"pnpm\", args: [\"test\"] }\n```\n\nThe tool resolves the environment, injects every granted secret into the child\nprocess only, and returns the exit code and captured output. It does not return\nvalues, and there is no tool that does.\n\nIn a terminal on the same host, the same thing is `seekrit run -- pnpm test`.\n\n## Why not a `secret_ref`\n\nPaperclip resolves a `secret_ref` through its own provider system, and that\nprovider list is closed: `local_encrypted`, `aws_secrets_manager`,\n`gcp_secret_manager`, `vault`. There is no seekrit provider to select, so there\nis no honest way to express a seekrit secret as a Paperclip secret.\n\nThe dishonest way — copying a value out of seekrit and into Paperclip's store —\nis worse than it looks. It creates a second plaintext copy under a different\nlifecycle, so a rotation in seekrit silently leaves Paperclip serving the old\nvalue, and a seekrit revocation does not revoke anything. Both paths above avoid\nthat by keeping resolution at the moment of use.\n\nThe one thing that *should* be a Paperclip `secret_ref` is the plugin's own\nseekrit service token, in step 1.\n"
|
|
40
|
+
}]
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/manifest.ts
|
|
45
|
+
/**
|
|
46
|
+
* Where each seekrit call is aimed, as tool parameters.
|
|
47
|
+
*
|
|
48
|
+
* Every tool takes these and every one of them is optional: the plugin's own
|
|
49
|
+
* config carries the default app and environment, so the common case is a tool
|
|
50
|
+
* call with no target at all. An agent that names one is either working across
|
|
51
|
+
* environments on purpose or has been told to — and in both cases the token's
|
|
52
|
+
* grants still decide what it can actually reach. These parameters cannot widen
|
|
53
|
+
* access; they can only aim it somewhere the token already reaches.
|
|
54
|
+
*/
|
|
55
|
+
const TARGET_PROPERTIES = {
|
|
56
|
+
app: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Application slug. Defaults to the plugin's configured app."
|
|
59
|
+
},
|
|
60
|
+
env: {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "Environment slug. Defaults to the plugin's configured environment."
|
|
63
|
+
},
|
|
64
|
+
group: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Target a group environment instead of an app."
|
|
67
|
+
},
|
|
68
|
+
branch: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Read a branch of the environment rather than the environment itself."
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const manifest = {
|
|
74
|
+
id: "seekrit",
|
|
75
|
+
apiVersion: 1,
|
|
76
|
+
version,
|
|
77
|
+
displayName: "seekrit",
|
|
78
|
+
description: "End-to-end encrypted secrets for Paperclip agents: inject credentials into a run without the run — or Paperclip — ever holding a value.",
|
|
79
|
+
author: "seekrit",
|
|
80
|
+
categories: ["connector"],
|
|
81
|
+
minimumHostVersion: "2026.817.0",
|
|
82
|
+
capabilities: [
|
|
83
|
+
"agent.tools.register",
|
|
84
|
+
"secrets.read-ref",
|
|
85
|
+
"skills.managed",
|
|
86
|
+
"plugin.state.read",
|
|
87
|
+
"plugin.state.write",
|
|
88
|
+
"ui.page.register",
|
|
89
|
+
"activity.log.write"
|
|
90
|
+
],
|
|
91
|
+
entrypoints: {
|
|
92
|
+
worker: "dist/worker.js",
|
|
93
|
+
ui: "dist/ui/"
|
|
94
|
+
},
|
|
95
|
+
instanceConfigSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
required: ["tokenRef"],
|
|
98
|
+
properties: {
|
|
99
|
+
tokenRef: {
|
|
100
|
+
title: "seekrit service token",
|
|
101
|
+
format: "secret-ref",
|
|
102
|
+
description: "A seekrit service token (skt_…) with grants for the environments these agents may reach. Scope it narrowly; it is the ceiling on everything below."
|
|
103
|
+
},
|
|
104
|
+
app: {
|
|
105
|
+
type: "string",
|
|
106
|
+
title: "Default application",
|
|
107
|
+
description: "Application slug tools use when a call names no app."
|
|
108
|
+
},
|
|
109
|
+
env: {
|
|
110
|
+
type: "string",
|
|
111
|
+
title: "Default environment",
|
|
112
|
+
description: "Environment slug tools use when a call names no environment."
|
|
113
|
+
},
|
|
114
|
+
group: {
|
|
115
|
+
type: "string",
|
|
116
|
+
title: "Default group",
|
|
117
|
+
description: "Group slug, for tokens scoped to a group environment instead of an app."
|
|
118
|
+
},
|
|
119
|
+
allowRunCommand: {
|
|
120
|
+
type: "boolean",
|
|
121
|
+
title: "Allow run_command",
|
|
122
|
+
default: true,
|
|
123
|
+
description: "Let agents execute commands with secrets injected. Turn this off to leave only the read-only and write-only tools."
|
|
124
|
+
},
|
|
125
|
+
cliPath: {
|
|
126
|
+
type: "string",
|
|
127
|
+
title: "seekrit CLI path",
|
|
128
|
+
default: "seekrit",
|
|
129
|
+
description: "Executable to invoke. Change this when the CLI is not on the Paperclip host's PATH."
|
|
130
|
+
},
|
|
131
|
+
timeoutSeconds: {
|
|
132
|
+
type: "number",
|
|
133
|
+
title: "Command timeout (seconds)",
|
|
134
|
+
default: 300,
|
|
135
|
+
minimum: 5,
|
|
136
|
+
maximum: 3600,
|
|
137
|
+
description: "Hard cap on a single tool invocation."
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
tools: [
|
|
142
|
+
{
|
|
143
|
+
name: "list_secrets",
|
|
144
|
+
displayName: "List seekrit secrets",
|
|
145
|
+
description: "List the secret names available in a seekrit environment, with versions and when each last changed. Returns metadata only — never a value. Use this to check whether a credential exists, or which name it has, before asking a human for it.",
|
|
146
|
+
parametersSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: { ...TARGET_PROPERTIES }
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "run_command",
|
|
153
|
+
displayName: "Run a command with secrets",
|
|
154
|
+
description: "Run a command with every granted secret injected as an environment variable in that child process only, and return its exit code and output. This is the right way to run anything that needs a credential — a build, a test suite, a migration, a dev server — because no value is written to a file or to your transcript. Note that this tool cannot redact the command's own output: a program that prints its credential has leaked it.",
|
|
155
|
+
parametersSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
required: ["command"],
|
|
158
|
+
properties: {
|
|
159
|
+
command: {
|
|
160
|
+
type: "string",
|
|
161
|
+
description: "Executable to run, e.g. `pnpm`."
|
|
162
|
+
},
|
|
163
|
+
args: {
|
|
164
|
+
type: "array",
|
|
165
|
+
items: { type: "string" },
|
|
166
|
+
description: "Arguments, e.g. `[\"test\"]`."
|
|
167
|
+
},
|
|
168
|
+
cwd: {
|
|
169
|
+
type: "string",
|
|
170
|
+
description: "Working directory. Defaults to the run's workspace."
|
|
171
|
+
},
|
|
172
|
+
...TARGET_PROPERTIES
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "store_secret",
|
|
178
|
+
displayName: "Store a secret in seekrit",
|
|
179
|
+
description: "Encrypt and store a value in seekrit. Write-only: there is no tool that reads a value back. Use this the moment a human hands you a credential — then reply naming the secret, never the value, and tell them the message they pasted it into still holds the plaintext.",
|
|
180
|
+
parametersSchema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
required: ["name", "value"],
|
|
183
|
+
properties: {
|
|
184
|
+
name: {
|
|
185
|
+
type: "string",
|
|
186
|
+
description: "Secret name, e.g. `STRIPE_SECRET_KEY`."
|
|
187
|
+
},
|
|
188
|
+
value: {
|
|
189
|
+
type: "string",
|
|
190
|
+
description: "The value to encrypt and store."
|
|
191
|
+
},
|
|
192
|
+
...TARGET_PROPERTIES
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "proxy_config",
|
|
198
|
+
displayName: "Generate an egress proxy config",
|
|
199
|
+
description: "Generate a seekrit-proxy.toml for the upstreams a workload calls, and return it as text. The proxy holds the credentials and swaps `{{seekrit:NAME}}` placeholders into outbound requests, so the workload never has a key at all. Use this instead of run_command whenever the thing that needs the credential is a sandbox, generated code, or a subagent whose output you cannot predict.",
|
|
200
|
+
parametersSchema: {
|
|
201
|
+
type: "object",
|
|
202
|
+
required: ["presets"],
|
|
203
|
+
properties: {
|
|
204
|
+
presets: {
|
|
205
|
+
type: "array",
|
|
206
|
+
items: { type: "string" },
|
|
207
|
+
minItems: 1,
|
|
208
|
+
description: "Upstream preset ids, e.g. `[\"anthropic\", \"openai\", \"gemini\", \"github\"]`."
|
|
209
|
+
},
|
|
210
|
+
mode: {
|
|
211
|
+
type: "string",
|
|
212
|
+
enum: [
|
|
213
|
+
"forward",
|
|
214
|
+
"reverse",
|
|
215
|
+
"both"
|
|
216
|
+
],
|
|
217
|
+
default: "forward",
|
|
218
|
+
description: "`forward` uses HTTPS_PROXY and needs no base-URL changes — usually right for a CLI runtime. `reverse` gives each upstream a base URL to point an SDK at."
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
],
|
|
224
|
+
skills: MANAGED_SKILLS,
|
|
225
|
+
ui: { slots: [{
|
|
226
|
+
type: "page",
|
|
227
|
+
id: "secrets",
|
|
228
|
+
displayName: "seekrit",
|
|
229
|
+
exportName: "SecretsPage",
|
|
230
|
+
routePath: "seekrit"
|
|
231
|
+
}] }
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
export { MANAGED_SKILLS as n, manifest as t };
|
package/dist/ui/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { DataTable, KeyValueList, MetricCard, Spinner, StatusBadge, useHostContext, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
//#region src/ui/index.tsx
|
|
4
|
+
/**
|
|
5
|
+
* The company page mounted at `/:companyPrefix/seekrit`.
|
|
6
|
+
*
|
|
7
|
+
* What it is for: answering "what can our agents actually reach?" without
|
|
8
|
+
* anybody having to read a value. Names, versions, and when each last changed
|
|
9
|
+
* are exactly the metadata that makes a secret store reviewable, and they are
|
|
10
|
+
* also the only fields the worker will hand over — there is no data key here
|
|
11
|
+
* that could return a plaintext, because there is no tool that reads one.
|
|
12
|
+
*
|
|
13
|
+
* When seekrit is unreachable the worker returns its last good snapshot with
|
|
14
|
+
* `live: false`. The page says so rather than rendering an empty table, because
|
|
15
|
+
* "no secrets" and "could not ask" are very different answers and a blank table
|
|
16
|
+
* is the wrong one for both.
|
|
17
|
+
*
|
|
18
|
+
* Bundled as ESM against the host's React. Everything visual comes from the
|
|
19
|
+
* SDK's component kit so the page inherits the host's theme instead of
|
|
20
|
+
* approximating it.
|
|
21
|
+
*/
|
|
22
|
+
/** `2026-08-22T09:14:00.000Z` → `2026-08-22 09:14`, or the raw string. */
|
|
23
|
+
function short(timestamp) {
|
|
24
|
+
if (!timestamp) return "never";
|
|
25
|
+
const parsed = new Date(timestamp);
|
|
26
|
+
if (Number.isNaN(parsed.getTime())) return timestamp;
|
|
27
|
+
return parsed.toISOString().replace("T", " ").slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
function SecretsPage() {
|
|
30
|
+
const companyId = useHostContext().companyId;
|
|
31
|
+
const { data, loading, error, refresh } = usePluginData("catalogue", { companyId });
|
|
32
|
+
const installSkills = usePluginAction("install-skills");
|
|
33
|
+
if (!companyId) return /* @__PURE__ */ jsx("p", { children: "Pick a company to see the secrets its agents can reach." });
|
|
34
|
+
if (loading && !data) return /* @__PURE__ */ jsx(Spinner, {});
|
|
35
|
+
if (error) return /* @__PURE__ */ jsxs("div", {
|
|
36
|
+
style: {
|
|
37
|
+
display: "grid",
|
|
38
|
+
gap: 12
|
|
39
|
+
},
|
|
40
|
+
children: [/* @__PURE__ */ jsx(StatusBadge, {
|
|
41
|
+
label: "Plugin unavailable",
|
|
42
|
+
status: "error"
|
|
43
|
+
}), /* @__PURE__ */ jsx("p", { children: error.message })]
|
|
44
|
+
});
|
|
45
|
+
const catalogue = data;
|
|
46
|
+
const secrets = catalogue?.secrets ?? [];
|
|
47
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
48
|
+
style: {
|
|
49
|
+
display: "grid",
|
|
50
|
+
gap: 20
|
|
51
|
+
},
|
|
52
|
+
children: [
|
|
53
|
+
/* @__PURE__ */ jsxs("div", {
|
|
54
|
+
style: {
|
|
55
|
+
display: "flex",
|
|
56
|
+
gap: 12,
|
|
57
|
+
flexWrap: "wrap",
|
|
58
|
+
alignItems: "center"
|
|
59
|
+
},
|
|
60
|
+
children: [
|
|
61
|
+
/* @__PURE__ */ jsx(StatusBadge, {
|
|
62
|
+
label: catalogue?.live ? "Connected" : "Showing last known",
|
|
63
|
+
status: catalogue?.live ? "ok" : "warning"
|
|
64
|
+
}),
|
|
65
|
+
catalogue?.allowRunCommand === false ? /* @__PURE__ */ jsx(StatusBadge, {
|
|
66
|
+
label: "run_command off",
|
|
67
|
+
status: "info"
|
|
68
|
+
}) : null,
|
|
69
|
+
/* @__PURE__ */ jsx("button", {
|
|
70
|
+
type: "button",
|
|
71
|
+
onClick: () => refresh(),
|
|
72
|
+
children: "Refresh"
|
|
73
|
+
}),
|
|
74
|
+
/* @__PURE__ */ jsx("button", {
|
|
75
|
+
type: "button",
|
|
76
|
+
onClick: () => {
|
|
77
|
+
installSkills({ companyId }).then(() => refresh());
|
|
78
|
+
},
|
|
79
|
+
children: "Install seekrit skills"
|
|
80
|
+
})
|
|
81
|
+
]
|
|
82
|
+
}),
|
|
83
|
+
catalogue?.error ? /* @__PURE__ */ jsxs("p", { children: [
|
|
84
|
+
"Could not reach seekrit: ",
|
|
85
|
+
catalogue.error,
|
|
86
|
+
" — the table below is the last snapshot this plugin took."
|
|
87
|
+
] }) : null,
|
|
88
|
+
/* @__PURE__ */ jsxs("div", {
|
|
89
|
+
style: {
|
|
90
|
+
display: "flex",
|
|
91
|
+
gap: 16,
|
|
92
|
+
flexWrap: "wrap"
|
|
93
|
+
},
|
|
94
|
+
children: [/* @__PURE__ */ jsx(MetricCard, {
|
|
95
|
+
label: "Secrets reachable",
|
|
96
|
+
value: secrets.length
|
|
97
|
+
}), /* @__PURE__ */ jsx(MetricCard, {
|
|
98
|
+
label: "Environment",
|
|
99
|
+
value: catalogue?.target ?? "—"
|
|
100
|
+
})]
|
|
101
|
+
}),
|
|
102
|
+
/* @__PURE__ */ jsx(KeyValueList, { pairs: [{
|
|
103
|
+
label: "Last read",
|
|
104
|
+
value: short(catalogue?.fetchedAt ?? null)
|
|
105
|
+
}, {
|
|
106
|
+
label: "How values reach a run",
|
|
107
|
+
value: "Injected into a child process by run_command, or swapped in by the egress proxy. Never copied into Paperclip's secret store."
|
|
108
|
+
}] }),
|
|
109
|
+
/* @__PURE__ */ jsx(DataTable, {
|
|
110
|
+
columns: [
|
|
111
|
+
{
|
|
112
|
+
key: "name",
|
|
113
|
+
header: "Name",
|
|
114
|
+
sortable: true
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
key: "version",
|
|
118
|
+
header: "Version",
|
|
119
|
+
width: "100px"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: "updatedAt",
|
|
123
|
+
header: "Last changed",
|
|
124
|
+
width: "180px",
|
|
125
|
+
render: (value) => short(typeof value === "string" ? value : null)
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
rows: secrets,
|
|
129
|
+
loading,
|
|
130
|
+
emptyMessage: catalogue?.live ? `No secrets in ${catalogue.target}. Check the plugin's default app and environment.` : "Nothing read yet."
|
|
131
|
+
})
|
|
132
|
+
]
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
export { SecretsPage };
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { n as MANAGED_SKILLS, t as manifest } from "./shared.js";
|
|
2
|
+
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
//#region src/seekrit-cli.ts
|
|
5
|
+
/**
|
|
6
|
+
* The plugin's only way to reach seekrit: the `seekrit` CLI, as a subprocess.
|
|
7
|
+
*
|
|
8
|
+
* WHY A SUBPROCESS AND NOT AN SDK. Two reasons, and the second is the load-
|
|
9
|
+
* bearing one.
|
|
10
|
+
*
|
|
11
|
+
* 1. No duplicated logic. Target resolution (`--app`/`--env`/`--group`/
|
|
12
|
+
* `--branch`), token auth, the last-known-good cache, `${REFERENCE}`
|
|
13
|
+
* expansion, and precedence (process env > .env > app > group) are all
|
|
14
|
+
* decided by one implementation. A second one drifts.
|
|
15
|
+
* 2. **The plugin never holds a plaintext.** `seekrit run` decrypts inside the
|
|
16
|
+
* child it then execs; this process only ever sees the child's exit code and
|
|
17
|
+
* output. That is a materially different blast radius from a plugin that
|
|
18
|
+
* decrypts in its own address space and passes values around — the Paperclip
|
|
19
|
+
* worker is long-lived, its heap is not something we want credentials in, and
|
|
20
|
+
* an out-of-process boundary is the only version of "never cached" that a
|
|
21
|
+
* reviewer can verify by reading one file.
|
|
22
|
+
*
|
|
23
|
+
* A consequence worth stating plainly: because this module never sees the
|
|
24
|
+
* injected values, it **cannot redact them** from captured output. A child that
|
|
25
|
+
* prints its own credential has leaked it, and no wrapper here can undo that.
|
|
26
|
+
* The tool descriptions say so.
|
|
27
|
+
*
|
|
28
|
+
* STDIO IS NOT INHERITABLE HERE. A Paperclip plugin worker talks to the host
|
|
29
|
+
* over JSON-RPC on its own stdin/stdout. A subprocess that inherits stdio would
|
|
30
|
+
* interleave the child's bytes into that framing and take the worker down with a
|
|
31
|
+
* parse error — so every spawn below pipes, and stdin is closed unless we are
|
|
32
|
+
* deliberately feeding a value in.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Captured output cap. A test suite or a dev server can emit megabytes, and all
|
|
36
|
+
* of it would otherwise land in an agent's context window and a run transcript.
|
|
37
|
+
* The tail is what a failure explains itself with, so that is the half kept.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_CAPTURE = 64 * 1024;
|
|
40
|
+
function tail(chunks) {
|
|
41
|
+
const joined = chunks.join("");
|
|
42
|
+
if (joined.length <= MAX_CAPTURE) return {
|
|
43
|
+
text: joined,
|
|
44
|
+
truncated: false
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
text: joined.slice(joined.length - MAX_CAPTURE),
|
|
48
|
+
truncated: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Target flags, in the order the CLI documents them. */
|
|
52
|
+
function targetArgs(target) {
|
|
53
|
+
const args = [];
|
|
54
|
+
if (target.org) args.push("--org", target.org);
|
|
55
|
+
if (target.app) args.push("--app", target.app);
|
|
56
|
+
if (target.group) args.push("--group", target.group);
|
|
57
|
+
if (target.branch) args.push("--branch", target.branch);
|
|
58
|
+
if (target.env) args.push("--env", target.env);
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Raised when the CLI itself fails, as opposed to the command it ran.
|
|
63
|
+
*
|
|
64
|
+
* The message carries the CLI's stderr because that is where its actionable
|
|
65
|
+
* errors go ("no secret named X", "token has no grant for that environment").
|
|
66
|
+
* It carries no environment and no argv beyond the subcommand, so a token can
|
|
67
|
+
* never reach a Paperclip activity row through an error path.
|
|
68
|
+
*/
|
|
69
|
+
var SeekritCliError = class extends Error {
|
|
70
|
+
subcommand;
|
|
71
|
+
exitCode;
|
|
72
|
+
stderr;
|
|
73
|
+
constructor(subcommand, exitCode, stderr) {
|
|
74
|
+
super(`seekrit ${subcommand} failed (exit ${exitCode})${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
|
|
75
|
+
this.subcommand = subcommand;
|
|
76
|
+
this.exitCode = exitCode;
|
|
77
|
+
this.stderr = stderr;
|
|
78
|
+
this.name = "SeekritCliError";
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Spawn the CLI and capture it. `stdin` is written and closed when provided,
|
|
83
|
+
* which is how a value is passed without it ever appearing in argv — and
|
|
84
|
+
* therefore never in `ps`, never in a shell history, and never in the host's
|
|
85
|
+
* process table.
|
|
86
|
+
*/
|
|
87
|
+
function runSeekrit(options, args, stdin) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const child = spawn(options.bin, args, {
|
|
90
|
+
stdio: [
|
|
91
|
+
stdin === void 0 ? "ignore" : "pipe",
|
|
92
|
+
"pipe",
|
|
93
|
+
"pipe"
|
|
94
|
+
],
|
|
95
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
96
|
+
env: {
|
|
97
|
+
...process.env,
|
|
98
|
+
SEEKRIT_TOKEN: options.token,
|
|
99
|
+
NO_COLOR: "1"
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const out = [];
|
|
103
|
+
const err = [];
|
|
104
|
+
let settled = false;
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
child.kill("SIGKILL");
|
|
109
|
+
reject(/* @__PURE__ */ new Error(`seekrit ${args[0] ?? "?"} timed out after ${options.timeoutMs}ms`));
|
|
110
|
+
}, options.timeoutMs);
|
|
111
|
+
child.stdout?.setEncoding("utf8");
|
|
112
|
+
child.stderr?.setEncoding("utf8");
|
|
113
|
+
child.stdout?.on("data", (chunk) => out.push(chunk));
|
|
114
|
+
child.stderr?.on("data", (chunk) => err.push(chunk));
|
|
115
|
+
child.on("error", (error) => {
|
|
116
|
+
if (settled) return;
|
|
117
|
+
settled = true;
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
const code = error.code;
|
|
120
|
+
reject(code === "ENOENT" ? /* @__PURE__ */ new Error(`could not run "${options.bin}" — install the seekrit CLI on the Paperclip host (npm i -g @seekrit/cli) or set cliPath in this plugin's settings`) : error);
|
|
121
|
+
});
|
|
122
|
+
child.on("close", (code) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
const stdout = tail(out);
|
|
127
|
+
const stderr = tail(err);
|
|
128
|
+
resolve({
|
|
129
|
+
exitCode: code ?? 1,
|
|
130
|
+
stdout: stdout.text,
|
|
131
|
+
stderr: stderr.text,
|
|
132
|
+
truncated: stdout.truncated || stderr.truncated
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
if (stdin !== void 0) child.stdin?.end(stdin);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** `seekrit secrets list --json` — names and metadata, never values. */
|
|
139
|
+
async function listSecrets(options, target) {
|
|
140
|
+
const result = await runSeekrit(options, [
|
|
141
|
+
"secrets",
|
|
142
|
+
"list",
|
|
143
|
+
"--json",
|
|
144
|
+
...targetArgs(target)
|
|
145
|
+
]);
|
|
146
|
+
if (result.exitCode !== 0) throw new SeekritCliError("secrets list", result.exitCode, result.stderr);
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(result.stdout);
|
|
150
|
+
} catch {
|
|
151
|
+
throw new SeekritCliError("secrets list", 0, "the CLI did not return JSON — check that `seekrit` on this host is recent enough for --json");
|
|
152
|
+
}
|
|
153
|
+
const rows = parsed.secrets;
|
|
154
|
+
if (!Array.isArray(rows)) return [];
|
|
155
|
+
return rows.map((row) => {
|
|
156
|
+
const r = row;
|
|
157
|
+
return {
|
|
158
|
+
name: String(r.name ?? ""),
|
|
159
|
+
version: Number(r.version ?? 0),
|
|
160
|
+
createdAt: String(r.createdAt ?? ""),
|
|
161
|
+
updatedAt: String(r.updatedAt ?? "")
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** `seekrit secrets set NAME` with the value on stdin. Write-only by design. */
|
|
166
|
+
async function storeSecret(options, target, name, value) {
|
|
167
|
+
const result = await runSeekrit(options, [
|
|
168
|
+
"secrets",
|
|
169
|
+
"set",
|
|
170
|
+
name,
|
|
171
|
+
...targetArgs(target)
|
|
172
|
+
], value);
|
|
173
|
+
if (result.exitCode !== 0) throw new SeekritCliError("secrets set", result.exitCode, result.stderr);
|
|
174
|
+
}
|
|
175
|
+
/** `seekrit run -- <command>` — secrets exist only inside the child. */
|
|
176
|
+
async function runCommand(options, target, command, commandArgs) {
|
|
177
|
+
return runSeekrit(options, [
|
|
178
|
+
"run",
|
|
179
|
+
...targetArgs(target),
|
|
180
|
+
"--",
|
|
181
|
+
command,
|
|
182
|
+
...commandArgs
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/worker.ts
|
|
187
|
+
/**
|
|
188
|
+
* The plugin worker: four agent tools, a company page, and nothing else.
|
|
189
|
+
*
|
|
190
|
+
* WHAT THIS PLUGIN IS FOR. Paperclip's secret provider list is closed
|
|
191
|
+
* (`local_encrypted`, `aws_secrets_manager`, `gcp_secret_manager`, `vault`), so
|
|
192
|
+
* a seekrit secret cannot be expressed as a Paperclip `secret_ref` — there is
|
|
193
|
+
* nothing to select. This plugin is the other half of that answer: instead of
|
|
194
|
+
* teaching Paperclip to resolve seekrit secrets into an agent's environment, it
|
|
195
|
+
* gives the agent verbs that keep resolution at the moment of use. A build runs
|
|
196
|
+
* with secrets injected into the child only; an untrusted workload gets a proxy
|
|
197
|
+
* config and a placeholder. Paperclip's database ends up holding one credential
|
|
198
|
+
* — this plugin's own seekrit service token, as a `secret_ref` — and nothing
|
|
199
|
+
* else.
|
|
200
|
+
*
|
|
201
|
+
* NO PLAINTEXT IS CACHED. `ctx.secrets.resolve` is called per invocation and the
|
|
202
|
+
* token is handed straight to a subprocess environment. It is never stored in
|
|
203
|
+
* `ctx.state`, never written to an activity row, and never included in an error.
|
|
204
|
+
* The same goes for the values the CLI resolves: this process never sees them at
|
|
205
|
+
* all (see `seekrit-cli.ts`).
|
|
206
|
+
*
|
|
207
|
+
* MULTI-COMPANY BY CONSTRUCTION. `multiCompanyConfig` is true because the worker
|
|
208
|
+
* holds *no* per-company state: every entry point re-reads `ctx.config.get`
|
|
209
|
+
* for the company it was called about. There is no cached config to collapse
|
|
210
|
+
* across tenants, which is the failure that flag exists to prevent.
|
|
211
|
+
*/
|
|
212
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
213
|
+
function str(value) {
|
|
214
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Read and validate the config for one company.
|
|
218
|
+
*
|
|
219
|
+
* Fails loudly on a missing `tokenRef` rather than falling back to ambient
|
|
220
|
+
* credentials. A `seekrit` CLI on the host may well have a `~/.config/seekrit`
|
|
221
|
+
* of its own from someone's manual testing, and silently using it would mean
|
|
222
|
+
* agents reaching environments nobody granted them — the config being *absent*
|
|
223
|
+
* would look like it was working.
|
|
224
|
+
*/
|
|
225
|
+
function readConfig(raw) {
|
|
226
|
+
const tokenRef = raw.tokenRef;
|
|
227
|
+
if (tokenRef === void 0 || tokenRef === null || tokenRef === "") throw new Error("no seekrit service token configured — set one in Settings → Plugins → seekrit. The plugin will not fall back to an ambient credential on the host.");
|
|
228
|
+
const timeoutSeconds = typeof raw.timeoutSeconds === "number" && Number.isFinite(raw.timeoutSeconds) ? raw.timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
|
229
|
+
return {
|
|
230
|
+
tokenRef,
|
|
231
|
+
target: {
|
|
232
|
+
...str(raw.app) ? { app: str(raw.app) } : {},
|
|
233
|
+
...str(raw.env) ? { env: str(raw.env) } : {},
|
|
234
|
+
...str(raw.group) ? { group: str(raw.group) } : {}
|
|
235
|
+
},
|
|
236
|
+
allowRunCommand: raw.allowRunCommand !== false,
|
|
237
|
+
bin: str(raw.cliPath) ?? "seekrit",
|
|
238
|
+
timeoutMs: Math.round(timeoutSeconds * 1e3)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Merge a tool call's target over the configured default.
|
|
243
|
+
*
|
|
244
|
+
* Naming `group` drops the configured `app` and vice versa: the CLI treats them
|
|
245
|
+
* as alternatives, and passing both produces an error that reads like a bug in
|
|
246
|
+
* the tool rather than a contradiction in the call.
|
|
247
|
+
*/
|
|
248
|
+
function mergeTarget(config, params) {
|
|
249
|
+
const app = params.app ?? (params.group ? void 0 : config.app);
|
|
250
|
+
const group = params.group ?? (params.app ? void 0 : config.group);
|
|
251
|
+
return {
|
|
252
|
+
...app ? { app } : {},
|
|
253
|
+
...group ? { group } : {},
|
|
254
|
+
...params.env ?? config.env ? { env: params.env ?? config.env } : {},
|
|
255
|
+
...params.branch ? { branch: params.branch } : {},
|
|
256
|
+
...params.org ? { org: params.org } : {}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** Tool params carry the target fields plus whatever else the tool takes. */
|
|
260
|
+
function targetFrom(params) {
|
|
261
|
+
const p = params ?? {};
|
|
262
|
+
return {
|
|
263
|
+
...str(p.org) ? { org: str(p.org) } : {},
|
|
264
|
+
...str(p.app) ? { app: str(p.app) } : {},
|
|
265
|
+
...str(p.env) ? { env: str(p.env) } : {},
|
|
266
|
+
...str(p.group) ? { group: str(p.group) } : {},
|
|
267
|
+
...str(p.branch) ? { branch: str(p.branch) } : {}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/** A human-readable label for the environment a call resolved to. */
|
|
271
|
+
function targetLabel(target) {
|
|
272
|
+
return `${target.app ?? (target.group ? `group:${target.group}` : "?")}/${target.branch ? `${target.env ?? "?"}@${target.branch}` : target.env ?? "?"}`;
|
|
273
|
+
}
|
|
274
|
+
/** Where the last good catalogue snapshot lives, per company. */
|
|
275
|
+
const snapshotKey = (companyId) => ({
|
|
276
|
+
scopeKind: "company",
|
|
277
|
+
scopeId: companyId,
|
|
278
|
+
namespace: "catalogue",
|
|
279
|
+
stateKey: "last-snapshot"
|
|
280
|
+
});
|
|
281
|
+
/**
|
|
282
|
+
* Everything a tool or data handler needs, resolved fresh for one company.
|
|
283
|
+
*
|
|
284
|
+
* The token resolution is the reason this is a function and not a cached object.
|
|
285
|
+
*/
|
|
286
|
+
async function cliFor(ctx, companyId) {
|
|
287
|
+
const config = readConfig(await ctx.config.get(companyId));
|
|
288
|
+
const token = await ctx.secrets.resolve(config.tokenRef, {
|
|
289
|
+
companyId,
|
|
290
|
+
configPath: "tokenRef"
|
|
291
|
+
});
|
|
292
|
+
return {
|
|
293
|
+
config,
|
|
294
|
+
cli: {
|
|
295
|
+
bin: config.bin,
|
|
296
|
+
token,
|
|
297
|
+
timeoutMs: config.timeoutMs
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** Turn any failure into a tool error the agent can act on, with no secret in it. */
|
|
302
|
+
function toolError(error) {
|
|
303
|
+
if (error instanceof SeekritCliError) return { error: error.message };
|
|
304
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
305
|
+
}
|
|
306
|
+
const plugin = definePlugin({
|
|
307
|
+
multiCompanyConfig: true,
|
|
308
|
+
async setup(ctx) {
|
|
309
|
+
ctx.tools.register("list_secrets", toolDeclaration("list_secrets"), async (params, runCtx) => {
|
|
310
|
+
try {
|
|
311
|
+
const { config, cli } = await cliFor(ctx, runCtx.companyId);
|
|
312
|
+
const target = mergeTarget(config.target, targetFrom(params));
|
|
313
|
+
const secrets = await listSecrets(cli, target);
|
|
314
|
+
await ctx.state.set(snapshotKey(runCtx.companyId), {
|
|
315
|
+
target: targetLabel(target),
|
|
316
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
317
|
+
secrets
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
content: secrets.length === 0 ? `No secrets in ${targetLabel(target)}.` : `${secrets.length} secret(s) in ${targetLabel(target)}: ${secrets.map((s) => s.name).join(", ")}`,
|
|
321
|
+
data: {
|
|
322
|
+
target: targetLabel(target),
|
|
323
|
+
secrets
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
} catch (error) {
|
|
327
|
+
return toolError(error);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
ctx.tools.register("run_command", toolDeclaration("run_command"), async (params, runCtx) => {
|
|
331
|
+
const p = params ?? {};
|
|
332
|
+
const command = str(p.command);
|
|
333
|
+
if (!command) return { error: "run_command needs a `command`" };
|
|
334
|
+
const args = Array.isArray(p.args) ? p.args.map(String) : [];
|
|
335
|
+
try {
|
|
336
|
+
const { config, cli } = await cliFor(ctx, runCtx.companyId);
|
|
337
|
+
if (!config.allowRunCommand) return { error: "run_command is turned off for this company. Ask an operator to enable it, or use proxy_config so the workload holds a placeholder instead of a key." };
|
|
338
|
+
const target = mergeTarget(config.target, targetFrom(params));
|
|
339
|
+
const cwd = str(p.cwd);
|
|
340
|
+
const result = await runCommand({
|
|
341
|
+
...cli,
|
|
342
|
+
...cwd ? { cwd } : {}
|
|
343
|
+
}, target, command, args);
|
|
344
|
+
await ctx.activity.log({
|
|
345
|
+
companyId: runCtx.companyId,
|
|
346
|
+
message: `seekrit run_command: ${command} (exit ${result.exitCode})`,
|
|
347
|
+
metadata: {
|
|
348
|
+
command,
|
|
349
|
+
argc: args.length,
|
|
350
|
+
exitCode: result.exitCode,
|
|
351
|
+
target: targetLabel(target),
|
|
352
|
+
agentId: runCtx.agentId,
|
|
353
|
+
runId: runCtx.runId
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return {
|
|
357
|
+
content: `exit ${result.exitCode}\n` + (result.truncated ? "(output truncated to the last 64 KiB)\n" : "") + (result.stdout ? `\n--- stdout ---\n${result.stdout}` : "") + (result.stderr ? `\n--- stderr ---\n${result.stderr}` : ""),
|
|
358
|
+
data: {
|
|
359
|
+
exitCode: result.exitCode,
|
|
360
|
+
truncated: result.truncated
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return toolError(error);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
ctx.tools.register("store_secret", toolDeclaration("store_secret"), async (params, runCtx) => {
|
|
368
|
+
const p = params ?? {};
|
|
369
|
+
const name = str(p.name);
|
|
370
|
+
if (!name) return { error: "store_secret needs a `name`" };
|
|
371
|
+
if (typeof p.value !== "string" || p.value === "") return { error: "store_secret needs a non-empty `value`" };
|
|
372
|
+
try {
|
|
373
|
+
const { config, cli } = await cliFor(ctx, runCtx.companyId);
|
|
374
|
+
const target = mergeTarget(config.target, targetFrom(params));
|
|
375
|
+
await storeSecret(cli, target, name, p.value);
|
|
376
|
+
await ctx.activity.log({
|
|
377
|
+
companyId: runCtx.companyId,
|
|
378
|
+
message: `seekrit store_secret: ${name} → ${targetLabel(target)}`,
|
|
379
|
+
metadata: {
|
|
380
|
+
name,
|
|
381
|
+
target: targetLabel(target),
|
|
382
|
+
agentId: runCtx.agentId,
|
|
383
|
+
runId: runCtx.runId
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return {
|
|
387
|
+
content: `Stored ${name} in ${targetLabel(target)}. Do not repeat the value back — and if a human pasted it into an issue or a comment, that message still holds the plaintext and should be edited or deleted.`,
|
|
388
|
+
data: {
|
|
389
|
+
name,
|
|
390
|
+
target: targetLabel(target)
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return toolError(error);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
ctx.tools.register("proxy_config", toolDeclaration("proxy_config"), async (params, runCtx) => {
|
|
398
|
+
const p = params ?? {};
|
|
399
|
+
const presets = Array.isArray(p.presets) ? p.presets.map(String).filter(Boolean) : [];
|
|
400
|
+
if (presets.length === 0) return { error: "proxy_config needs at least one preset, e.g. [\"anthropic\"]" };
|
|
401
|
+
const mode = str(p.mode) ?? "forward";
|
|
402
|
+
if (![
|
|
403
|
+
"forward",
|
|
404
|
+
"reverse",
|
|
405
|
+
"both"
|
|
406
|
+
].includes(mode)) return { error: `mode must be forward, reverse, or both (got "${mode}")` };
|
|
407
|
+
try {
|
|
408
|
+
const { cli } = await cliFor(ctx, runCtx.companyId);
|
|
409
|
+
const result = await runSeekrit(cli, [
|
|
410
|
+
"proxy",
|
|
411
|
+
"init",
|
|
412
|
+
"--print",
|
|
413
|
+
"--mode",
|
|
414
|
+
mode,
|
|
415
|
+
...presets.flatMap((id) => ["--preset", id])
|
|
416
|
+
]);
|
|
417
|
+
if (result.exitCode !== 0) return { error: new SeekritCliError("proxy init", result.exitCode, result.stderr).message };
|
|
418
|
+
return {
|
|
419
|
+
content: result.stdout,
|
|
420
|
+
data: {
|
|
421
|
+
mode,
|
|
422
|
+
presets
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
} catch (error) {
|
|
426
|
+
return toolError(error);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
ctx.data.register("catalogue", async (params) => {
|
|
430
|
+
const companyId = str(params.companyId);
|
|
431
|
+
if (!companyId) return { error: "no company in scope" };
|
|
432
|
+
const cached = await ctx.state.get(snapshotKey(companyId));
|
|
433
|
+
try {
|
|
434
|
+
const { config, cli } = await cliFor(ctx, companyId);
|
|
435
|
+
const target = mergeTarget(config.target, {});
|
|
436
|
+
const secrets = await listSecrets(cli, target);
|
|
437
|
+
const snapshot = {
|
|
438
|
+
target: targetLabel(target),
|
|
439
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
440
|
+
secrets
|
|
441
|
+
};
|
|
442
|
+
await ctx.state.set(snapshotKey(companyId), snapshot);
|
|
443
|
+
return {
|
|
444
|
+
...snapshot,
|
|
445
|
+
live: true,
|
|
446
|
+
allowRunCommand: config.allowRunCommand
|
|
447
|
+
};
|
|
448
|
+
} catch (error) {
|
|
449
|
+
return {
|
|
450
|
+
...cached ?? {
|
|
451
|
+
target: "?",
|
|
452
|
+
fetchedAt: null,
|
|
453
|
+
secrets: []
|
|
454
|
+
},
|
|
455
|
+
live: false,
|
|
456
|
+
error: toolError(error).error
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
ctx.actions.register("install-skills", async (params) => {
|
|
461
|
+
const companyId = str(params.companyId);
|
|
462
|
+
if (!companyId) throw new Error("no company in scope");
|
|
463
|
+
const installed = [];
|
|
464
|
+
for (const skill of MANAGED_SKILLS) {
|
|
465
|
+
await ctx.skills.managed.reconcile(skill.skillKey, companyId);
|
|
466
|
+
installed.push(skill.skillKey);
|
|
467
|
+
}
|
|
468
|
+
await ctx.activity.log({
|
|
469
|
+
companyId,
|
|
470
|
+
message: `seekrit: installed ${installed.length} skill(s) into the company library`,
|
|
471
|
+
metadata: { skills: installed }
|
|
472
|
+
});
|
|
473
|
+
return { installed };
|
|
474
|
+
});
|
|
475
|
+
ctx.logger.info("seekrit plugin ready", {
|
|
476
|
+
tools: manifest.tools?.map((t) => t.name),
|
|
477
|
+
skills: MANAGED_SKILLS.map((s) => s.skillKey)
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
async onValidateConfig(config) {
|
|
481
|
+
const errors = [];
|
|
482
|
+
const warnings = [];
|
|
483
|
+
if (!config.tokenRef) errors.push("A seekrit service token is required.");
|
|
484
|
+
else if (typeof config.tokenRef === "string") errors.push("The token must be a secret reference, not a pasted value — pick a secret with the picker so the value stays in Paperclip's secret store.");
|
|
485
|
+
if (!str(config.env)) warnings.push("No default environment. Tools will work only when a call names one explicitly.");
|
|
486
|
+
if (!str(config.app) && !str(config.group)) warnings.push("No default application or group. A token scoped to a single environment infers both; an org-scoped token does not.");
|
|
487
|
+
if (config.allowRunCommand === false) warnings.push("run_command is off — agents can list, store, and generate proxy configs only.");
|
|
488
|
+
return {
|
|
489
|
+
ok: errors.length === 0,
|
|
490
|
+
errors,
|
|
491
|
+
warnings
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
async onHealth() {
|
|
495
|
+
return {
|
|
496
|
+
status: "ok",
|
|
497
|
+
message: `${manifest.tools?.length ?? 0} tools registered`
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
runWorker(plugin, import.meta.url);
|
|
502
|
+
/** Pull a tool's declared metadata out of the manifest, so it is stated once. */
|
|
503
|
+
function toolDeclaration(name) {
|
|
504
|
+
const declared = manifest.tools?.find((t) => t.name === name);
|
|
505
|
+
if (!declared) throw new Error(`tool ${name} is not declared in the manifest`);
|
|
506
|
+
const { displayName, description, parametersSchema } = declared;
|
|
507
|
+
return {
|
|
508
|
+
displayName,
|
|
509
|
+
description,
|
|
510
|
+
parametersSchema
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
514
|
+
export { plugin as default, mergeTarget, targetLabel };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seekrit/paperclip-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "seekrit for Paperclip — agent tools that inject end-to-end encrypted secrets into a run without the run ever holding a value, plus the seekrit skills and a company secrets panel.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"paperclipPlugin": {
|
|
10
|
+
"manifest": "./dist/manifest.js",
|
|
11
|
+
"worker": "./dist/worker.js",
|
|
12
|
+
"ui": "./dist/ui/"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"paperclip",
|
|
22
|
+
"paperclip-plugin",
|
|
23
|
+
"secrets",
|
|
24
|
+
"api-keys",
|
|
25
|
+
"zero-knowledge",
|
|
26
|
+
"egress-proxy"
|
|
27
|
+
],
|
|
28
|
+
"homepage": "https://seekrit.dev/docs/guides/ai-agents/paperclip",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"gen": "node gen-skills.mjs src/skills.generated.ts",
|
|
32
|
+
"build": "pnpm gen && tsdown && tsdown --config tsdown.ui.config.ts",
|
|
33
|
+
"dev": "pnpm gen && tsdown --watch",
|
|
34
|
+
"test": "pnpm gen && vitest run",
|
|
35
|
+
"typecheck": "pnpm gen && tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@paperclipai/plugin-sdk": "2026.817.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^26.1.0",
|
|
42
|
+
"@types/react": "^19.2.17",
|
|
43
|
+
"react": "^19.2.0",
|
|
44
|
+
"tsdown": "^0.22.3",
|
|
45
|
+
"vitest": "^4.1.9"
|
|
46
|
+
}
|
|
47
|
+
}
|