secrets-axi 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +149 -2
- package/dist/bin/secrets-axi.js +13 -0
- package/dist/src/args.js +184 -0
- package/dist/src/cli.js +101 -0
- package/dist/src/commands/check.js +40 -0
- package/dist/src/commands/edit.js +56 -0
- package/dist/src/commands/home.js +65 -0
- package/dist/src/commands/keys.js +83 -0
- package/dist/src/commands/policy.js +72 -0
- package/dist/src/commands/run.js +116 -0
- package/dist/src/commands/scan.js +63 -0
- package/dist/src/commands/setup.js +83 -0
- package/dist/src/context.js +1 -0
- package/dist/src/env.js +124 -0
- package/dist/src/errors.js +12 -0
- package/dist/src/hook/entry.js +48 -0
- package/dist/src/hook/guard.js +351 -0
- package/dist/src/hook/tokenize.js +201 -0
- package/dist/src/mask.js +47 -0
- package/dist/src/meta.js +20 -0
- package/dist/src/policy.js +186 -0
- package/dist/src/render.js +149 -0
- package/dist/src/setup/claude.js +208 -0
- package/dist/src/setup/codex.js +20 -0
- package/dist/src/setup/index.js +129 -0
- package/dist/src/skill.js +40 -0
- package/dist/src/sources/aws.js +61 -0
- package/dist/src/sources/detect.js +23 -0
- package/dist/src/sources/dotenv.js +20 -0
- package/dist/src/version.js +23 -0
- package/package.json +58 -4
- package/skills/secrets-axi/SKILL.md +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cristian Mos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# secrets-axi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Agent-ergonomic **secrets guard**, built on the [AXI](https://axi.md) principles: token-efficient [TOON](https://toonformat.dev/) output, minimal schemas, definitive empty states, structured errors and next-step hints.
|
|
4
|
+
|
|
5
|
+
It keeps secret values out of agent transcripts. A **Guard** hook denies the obvious reads of `.env` and credential files (and of secret-fetching CLIs) and tells the agent the command to run instead; a **Runner** runs that command with the secrets injected and every injected value masked in its output. Misuse of credentials the agent legitimately holds is out of scope: that is credential hygiene, not a reader guard.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Requires Node.js 20+.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g secrets-axi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then install the Guard hook for your agent (Claude Code and Codex share one contract):
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
secrets-axi setup hooks # PreToolUse hook + Read(...) deny rules for the built-in Secret Files
|
|
19
|
+
secrets-axi setup status # what is installed; recommends a sandbox denyRead (never writes it)
|
|
20
|
+
secrets-axi setup uninstall # removes exactly the entries secrets-axi added
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`--project` installs for the current repository only. The hook is one bundled file with `node:` builtins only (budget: under 30 ms per call, pinned by a test). Optionally add the on-demand Agent Skill, and one line in `CLAUDE.md` / `AGENTS.md`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx skills add cmosgh/secrets-axi --skill secrets-axi
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Use `secrets-axi run -- <command>` to run anything that needs .env or other secrets; never read or source .env yourself.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Two faces
|
|
34
|
+
|
|
35
|
+
### Guard (hook)
|
|
36
|
+
|
|
37
|
+
Runs before every `Read`, `Edit`, `Write`, `Grep`, `Glob` and `Bash` call and decides from the literal call. Deterministic and listable: every decision comes from a [Policy](#policy) entry, `secrets-axi policy` prints them all, `secrets-axi check '<command>'` shows the decision the Guard would make and why.
|
|
38
|
+
|
|
39
|
+
It always **denies**, never `ask` ("approve once" is how values end up in transcripts), and never rewrites a call except one case: a directory-wide search (the Grep tool, `grep -r`, `rg`, `ag`) gets the Secret File patterns added as exclusions.
|
|
40
|
+
|
|
41
|
+
| Call | Decision |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `Read` / `Edit` / `Write` of a Secret File | deny; Hint: `secrets-axi keys <file>` or `secrets-axi set <file> KEY=value` |
|
|
44
|
+
| `Grep` with `path` a Secret File | deny; Hint: `secrets-axi keys <file>` |
|
|
45
|
+
| `Grep` over a directory, `grep -r` / `rg` / `ag` without an explicit Secret File | rewrite: exclusions for the Secret File patterns |
|
|
46
|
+
| `Bash` with a token that resolves to a Secret File | deny unless the program is an Allowed Consumer (`docker --env-file`, `dotenv -e`, `dotenvx run`, `secrets-axi`); Hint: `secrets-axi run -- <command>` |
|
|
47
|
+
| `Bash` matching a Secret Fetch (`aws secretsmanager get-secret-value`, `op read`, `vault kv get`, ...) | deny; Hint: `secrets-axi run --aws-secret <id> -- <command>` or the source's run form |
|
|
48
|
+
| `Bash` `git show <rev>:<secret file>` | deny; other git commands pass |
|
|
49
|
+
|
|
50
|
+
What the agent is told instead, for `Read` of `.env` (the hook's `permissionDecisionReason`):
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
secrets-axi: .env is a Secret File - its values must not enter the transcript. Run `secrets-axi keys .env` for its variable names and shapes, or `secrets-axi run -- <command>` to run something with it loaded
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A Hint is at most two lines with the exact replacement command, real paths and names filled in. Listing or describing secrets (names only) is not a Secret Fetch and passes.
|
|
57
|
+
|
|
58
|
+
### Runner (CLI)
|
|
59
|
+
|
|
60
|
+
Masking is the boundary, the hook is the nudge: every injected value is replaced by `<VARIABLE_NAME>` in the child's stdout and stderr, unconditionally, with no flag to switch it off (values shorter than four characters are not masked).
|
|
61
|
+
|
|
62
|
+
| Command | What it does |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `secrets-axi` | Home view: Guard status, Secret Files in the repository (name, key count), detected Secret Source, other env files not loaded, a warning when `.env` is tracked by git, the one `run` form to use |
|
|
65
|
+
| `run [--env-file F]... [--aws-secret ID[=NAME]]... -- <cmd>` | Guarded Run: resolve the secrets, spawn `<cmd>` with them in its environment, mask its output |
|
|
66
|
+
| `keys <file>` | Key listing: each variable's name, set or empty, and the shape of its value (length, character class). Values only for Reveal Keys (`NODE_ENV`, `PORT`, `VITE_*`, ...) |
|
|
67
|
+
| `set <file> KEY=value`, `unset <file> KEY` | Edit in place; prints the key name and `set` / `replaced` / `removed`, never any other line |
|
|
68
|
+
| `check '<command>'`, `check --read <path>` | Dry run of the Guard: the decision and the Policy entry behind it |
|
|
69
|
+
| `policy` | Effective Policy with the layer each entry comes from |
|
|
70
|
+
| `setup hooks\|status\|uninstall [--project]` | Hook and deny-rule management |
|
|
71
|
+
|
|
72
|
+
`secrets-axi <command> --help` prints the flag reference; `--json` prints the decoded payload anywhere.
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
secrets-axi run -- pnpm test
|
|
76
|
+
secrets-axi run --env-file .env.test -- pnpm vitest
|
|
77
|
+
secrets-axi run --aws-secret prod/db -- node scripts/migrate.js
|
|
78
|
+
secrets-axi keys .env
|
|
79
|
+
secrets-axi set .env FEATURE_X=true
|
|
80
|
+
secrets-axi check 'cat .env | grep DB'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
#### Secret Sources
|
|
84
|
+
|
|
85
|
+
The first match wins; `--source` overrides. A source only resolves values: the Runner merges them, spawns the child once and masks its output, so every source gets the same guarantee.
|
|
86
|
+
|
|
87
|
+
1. `.env` made of `op://` references: `op inject` (detected, not supported yet)
|
|
88
|
+
2. `doppler.yaml` / `.infisical.json`: that tool's export (detected, not supported yet)
|
|
89
|
+
3. `.env.keys` present: `dotenvx get --all` (detected, not supported yet)
|
|
90
|
+
4. plain `.env`: built-in parser
|
|
91
|
+
5. AWS Secrets Manager / SSM entries from the Policy or `--aws-secret`: AWS SDK, merged over the env file (a JSON object becomes one variable per top-level key; a bare string needs `ID=NAME`)
|
|
92
|
+
|
|
93
|
+
Only `.env` is loaded by default; `--env-file` is repeatable and ordered, and the Policy `envFiles` key sets the default. The `NODE_ENV` cascade (`.env.local`, `.env.production`, ...) is never applied.
|
|
94
|
+
|
|
95
|
+
## Policy
|
|
96
|
+
|
|
97
|
+
Three layers, merged in order: built-in, then `~/.config/secrets-axi/policy.json`, then `.secrets-axi.json` in the repository root. The repository file is optional, never required and never written by the tool. A repository layer may only add (and add Reveal Keys); `remove` entries are honoured from the user layer only, so a cloned repo can never switch the Guard off. `secrets-axi policy` shows each entry with its layer.
|
|
98
|
+
|
|
99
|
+
| Key | What it lists |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `secretFiles` | Secret File patterns by name (`.env`, `.env.*`, `*.pem`, `id_*`, `~/.aws/credentials`, `~/.npmrc`, ...), never by content; `.env.example`, `.env.sample`, `.env.template` and `*.pub` are exceptions |
|
|
102
|
+
| `fetchCommands` | Secret Fetch command prefixes (`aws ssm get-parameter --with-decryption`, `op read`, `vault kv get`, `doppler secrets`, ...) |
|
|
103
|
+
| `consumers` | Allowed Consumers: programs that load a Secret File without printing it |
|
|
104
|
+
| `reveal` | Reveal Keys: variables whose value a key listing may show |
|
|
105
|
+
| `envFiles` | Env files a Guarded Run loads, in order (default `.env`) |
|
|
106
|
+
| `aws` | AWS Secrets Manager ids (`prod/db`) or `id=NAME` for string secrets |
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"secretFiles": ["config/*.secret"],
|
|
111
|
+
"reveal": ["STRIPE_PUBLISHABLE_KEY"],
|
|
112
|
+
"envFiles": [".env", ".env.test"],
|
|
113
|
+
"aws": ["prod/db", "prod/api-key=API_KEY"]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Known limits
|
|
118
|
+
|
|
119
|
+
- The Guard sees the literal command: `python -c "open('.env')"` or `$(printf '.e''nv')` pass it. The hard layer (the `Read(...)` deny rules `setup hooks` writes) and the sandbox are the boundary for those; masking covers whatever a Guarded Run prints.
|
|
120
|
+
- A Secret File tracked by git leaks through `git diff` / `git log -p`; secrets-axi warns in the home view, it cannot cover it.
|
|
121
|
+
- A child of a Guarded Run can read the Secret File itself; Masking still covers what it prints. A value the Runner did not inject (a different Secret File the child reads on its own) is not masked, so load it through `run` too.
|
|
122
|
+
- Masking is by substring: values under four characters are not masked, and a short or common value can hit unrelated output. Encoded forms (base64, URL-encoded) are not masked yet.
|
|
123
|
+
- Membership of a Secret File is by name; a secret in an unnamed file is not seen (content sniffing is deferred).
|
|
124
|
+
- 0.1 resolves plain `.env` and AWS. `op`, `doppler`, `dotenvx` and `infisical` are detected and reported as "not supported yet"; the OpenCode plugin and Windows are deferred.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
corepack pnpm install
|
|
130
|
+
corepack pnpm run check # typecheck, tests, build, skill freshness
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`skills/secrets-axi/SKILL.md` is generated: `corepack pnpm run build:skill`. See `PLAN.md` for the spec, `GLOSSARY.md` for the vocabulary and `docs/adr/` for the design decisions (the hook is the nudge and masking is the boundary; deny with a Hint instead of rewriting; sources resolve and the Runner spawns).
|
|
134
|
+
|
|
135
|
+
Fixture secrets in tests are obviously fake (`FAKE_...`) and never real values; every test asserts they do not appear in any output.
|
|
136
|
+
|
|
137
|
+
<!-- axi-tools:start -->
|
|
138
|
+
## Other AXI tools
|
|
139
|
+
|
|
140
|
+
- [`atl-axi`](https://github.com/cmosgh/atl-axi): Atlassian Jira Cloud CLI for agents
|
|
141
|
+
- [`bb-axi`](https://github.com/cmosgh/bb-axi): Bitbucket Cloud CLI for agents
|
|
142
|
+
- [`hetzner-axi`](https://github.com/cmosgh/hetzner-axi): Hetzner Cloud CLI for agents
|
|
143
|
+
- [`mongo-axi`](https://github.com/cmosgh/mongo-axi): Read-only MongoDB CLI for agents, plus `mongo-rw-axi` (write-capable MongoDB CLI for agents, loopback hosts only)
|
|
144
|
+
- [`mysql-axi`](https://github.com/cmosgh/mysql-axi): Read-only MySQL CLI for agents, plus `mysql-rw-axi` (write-capable MySQL CLI for agents, loopback hosts only)
|
|
145
|
+
- [`ts-axi`](https://github.com/cmosgh/ts-axi): TypeScript symbol CLI for agents
|
|
146
|
+
<!-- axi-tools:end -->
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { tryFastPath } from "axi-sdk-js/fast-path";
|
|
3
|
+
import { VERSION } from "../src/version.js";
|
|
4
|
+
const argv = process.argv.slice(2);
|
|
5
|
+
// The hook runs on every agent tool call: load only the guard, never the CLI graph.
|
|
6
|
+
if (argv[0] === "hook") {
|
|
7
|
+
const { runHook } = await import("../src/hook/entry.js");
|
|
8
|
+
process.exitCode = await runHook();
|
|
9
|
+
}
|
|
10
|
+
else if (!tryFastPath(argv, { version: VERSION })) {
|
|
11
|
+
const { main } = await import("../src/cli.js");
|
|
12
|
+
await main();
|
|
13
|
+
}
|
package/dist/src/args.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { usageError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Flags agents commonly carry over from other CLIs. A targeted hint lets the
|
|
4
|
+
* agent self-correct in one turn instead of scanning the generic flag list.
|
|
5
|
+
*/
|
|
6
|
+
const RENAMED = {
|
|
7
|
+
"--env": "--env-file",
|
|
8
|
+
"--file": "--env-file",
|
|
9
|
+
"--secret": "--aws-secret",
|
|
10
|
+
"--all": "--fields all",
|
|
11
|
+
"--global": "(user scope is the default; --project targets the repository)",
|
|
12
|
+
};
|
|
13
|
+
/** Flags whose value is a positional argument in secrets-axi. */
|
|
14
|
+
const POSITIONAL = {
|
|
15
|
+
"--command": "pass the command after `--`",
|
|
16
|
+
"--cmd": "pass the command after `--`",
|
|
17
|
+
"--key": "pass KEY or KEY=value as a positional argument",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Strict declarative parser: every flag must be declared, unknown flags fail
|
|
21
|
+
* loud with the valid set inline (AXI principle 6). Supports `--flag value`,
|
|
22
|
+
* `--flag=value`, boolean flags, repeatable flags, aliases and a `--`
|
|
23
|
+
* terminator. A bare `-` is a positional (stdin sentinel).
|
|
24
|
+
*/
|
|
25
|
+
export function parseArgs(args, specs, command) {
|
|
26
|
+
const byName = new Map();
|
|
27
|
+
for (const spec of specs) {
|
|
28
|
+
byName.set(spec.name, spec);
|
|
29
|
+
if (spec.alias)
|
|
30
|
+
byName.set(spec.alias, spec);
|
|
31
|
+
}
|
|
32
|
+
const parsed = {
|
|
33
|
+
values: new Map(),
|
|
34
|
+
bools: new Set(),
|
|
35
|
+
positionals: [],
|
|
36
|
+
};
|
|
37
|
+
const unknown = [];
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
const token = args[i];
|
|
40
|
+
if (token === "--") {
|
|
41
|
+
parsed.positionals.push(...args.slice(i + 1));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (token === "-" || !token.startsWith("-")) {
|
|
45
|
+
parsed.positionals.push(token);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const eq = token.indexOf("=");
|
|
49
|
+
const name = eq === -1 ? token : token.slice(0, eq);
|
|
50
|
+
const inlineValue = eq === -1 ? undefined : token.slice(eq + 1);
|
|
51
|
+
if (name === "--help" || name === "-h")
|
|
52
|
+
continue;
|
|
53
|
+
const spec = byName.get(name);
|
|
54
|
+
if (!spec) {
|
|
55
|
+
if (!unknown.includes(name))
|
|
56
|
+
unknown.push(name);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (spec.boolean) {
|
|
60
|
+
if (inlineValue !== undefined) {
|
|
61
|
+
throw usageError(`${spec.name} does not take a value`, [
|
|
62
|
+
`Run \`${command} --help\``,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
parsed.bools.add(spec.name);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
let value = inlineValue;
|
|
69
|
+
if (value === undefined) {
|
|
70
|
+
const next = args[i + 1];
|
|
71
|
+
// A following declared flag (or `--`) means the value is missing. Other
|
|
72
|
+
// dash-leading tokens are accepted so bodies like "- item" still work.
|
|
73
|
+
if (next === undefined || next === "--" || byName.has(next)) {
|
|
74
|
+
throw usageError(`${spec.name} requires a value`, [
|
|
75
|
+
`Run \`${command} --help\``,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
value = next;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
if (value.trim() === "") {
|
|
82
|
+
throw usageError(`${spec.name} requires a non-empty value`);
|
|
83
|
+
}
|
|
84
|
+
const existing = parsed.values.get(spec.name);
|
|
85
|
+
if (existing && !spec.repeatable) {
|
|
86
|
+
throw usageError(`${spec.name} may only be given once`);
|
|
87
|
+
}
|
|
88
|
+
parsed.values.set(spec.name, [...(existing ?? []), value]);
|
|
89
|
+
}
|
|
90
|
+
if (unknown.length > 0) {
|
|
91
|
+
const valid = specs.map((s) => s.name).join(", ") || "(none)";
|
|
92
|
+
const renamed = unknown
|
|
93
|
+
.filter((flag) => RENAMED[flag] && byName.has(RENAMED[flag]))
|
|
94
|
+
.map((flag) => `${flag} is not supported here; use ${RENAMED[flag]} instead`);
|
|
95
|
+
const positional = unknown
|
|
96
|
+
.filter((flag) => POSITIONAL[flag])
|
|
97
|
+
.map((flag) => `${flag} is not supported; ${POSITIONAL[flag]}`);
|
|
98
|
+
throw usageError(`unknown flag${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} for \`${command}\``, [
|
|
99
|
+
...renamed,
|
|
100
|
+
...positional,
|
|
101
|
+
`valid flags for \`${command}\`: ${valid} (--help always allowed)`,
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
export function flag(parsed, name) {
|
|
107
|
+
return parsed.values.get(name)?.[0];
|
|
108
|
+
}
|
|
109
|
+
export function flags(parsed, name) {
|
|
110
|
+
return parsed.values.get(name) ?? [];
|
|
111
|
+
}
|
|
112
|
+
export function bool(parsed, name) {
|
|
113
|
+
return parsed.bools.has(name);
|
|
114
|
+
}
|
|
115
|
+
export function requireFlag(parsed, name, usage) {
|
|
116
|
+
const value = flag(parsed, name);
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
throw usageError(`${name} is required`, [usage]);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
export function intFlag(parsed, name, fallback, range) {
|
|
123
|
+
const raw = flag(parsed, name);
|
|
124
|
+
if (raw === undefined)
|
|
125
|
+
return fallback;
|
|
126
|
+
if (!/^\d+$/.test(raw)) {
|
|
127
|
+
throw usageError(`${name} must be a whole number, got '${raw}'`);
|
|
128
|
+
}
|
|
129
|
+
const value = Number(raw);
|
|
130
|
+
if (value < range.min || value > range.max) {
|
|
131
|
+
throw usageError(`${name} must be between ${range.min} and ${range.max}`);
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
export function enumFlag(parsed, name, allowed, fallback) {
|
|
136
|
+
const raw = flag(parsed, name);
|
|
137
|
+
if (raw === undefined)
|
|
138
|
+
return fallback;
|
|
139
|
+
const value = raw.toLowerCase();
|
|
140
|
+
if (!allowed.includes(value)) {
|
|
141
|
+
throw usageError(`invalid ${name} '${raw}'`, [
|
|
142
|
+
`valid values: ${allowed.join(", ")}`,
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
/** Require exactly `count` positionals; extra or missing ones are usage errors. */
|
|
148
|
+
export function expectPositionals(parsed, count, usage) {
|
|
149
|
+
if (parsed.positionals.length !== count) {
|
|
150
|
+
const problem = parsed.positionals.length < count
|
|
151
|
+
? "missing argument"
|
|
152
|
+
: `unexpected argument '${parsed.positionals[count]}'`;
|
|
153
|
+
throw usageError(problem, [`usage: ${usage}`]);
|
|
154
|
+
}
|
|
155
|
+
return parsed.positionals;
|
|
156
|
+
}
|
|
157
|
+
export function positiveId(raw, label) {
|
|
158
|
+
const cleaned = raw?.replace(/^#/, "");
|
|
159
|
+
if (!cleaned || !/^\d+$/.test(cleaned) || Number(cleaned) < 1) {
|
|
160
|
+
throw usageError(`invalid ${label} '${raw ?? ""}' - expected a positive number`);
|
|
161
|
+
}
|
|
162
|
+
return Number(cleaned);
|
|
163
|
+
}
|
|
164
|
+
/** Parse a comma-separated `--fields` value against the supported extras. */
|
|
165
|
+
export function parseFields(raw, available) {
|
|
166
|
+
if (raw === undefined)
|
|
167
|
+
return [];
|
|
168
|
+
const requested = [
|
|
169
|
+
...new Set(raw
|
|
170
|
+
.split(",")
|
|
171
|
+
.map((f) => f.trim().toLowerCase())
|
|
172
|
+
.filter(Boolean)),
|
|
173
|
+
];
|
|
174
|
+
const bad = requested.filter((f) => f !== "all" && !available.includes(f));
|
|
175
|
+
if (bad.length > 0) {
|
|
176
|
+
throw usageError(`unknown field${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}`, [
|
|
177
|
+
`available --fields: ${available.join(", ")} (or all)`,
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
if (requested.includes("all"))
|
|
181
|
+
return [...available];
|
|
182
|
+
// Columns keep the documented order whatever order they were asked in.
|
|
183
|
+
return available.filter((f) => requested.includes(f));
|
|
184
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { runAxiCli } from "axi-sdk-js";
|
|
2
|
+
import { checkCommand, CHECK_HELP } from "./commands/check.js";
|
|
3
|
+
import { SET_HELP, setCommand, UNSET_HELP, unsetCommand } from "./commands/edit.js";
|
|
4
|
+
import { homeCommand } from "./commands/home.js";
|
|
5
|
+
import { keysCommand, KEYS_HELP } from "./commands/keys.js";
|
|
6
|
+
import { policyCommand, POLICY_HELP } from "./commands/policy.js";
|
|
7
|
+
import { runCommand, RUN_HELP } from "./commands/run.js";
|
|
8
|
+
import { setupCommand, SETUP_HELP } from "./commands/setup.js";
|
|
9
|
+
import { AxiError, exitCodeForError } from "./errors.js";
|
|
10
|
+
import { DESCRIPTION, TOP_HELP } from "./meta.js";
|
|
11
|
+
import { renderErrorOutput, setJsonMode, toJson } from "./render.js";
|
|
12
|
+
import { VERSION } from "./version.js";
|
|
13
|
+
export { DESCRIPTION, TOP_HELP };
|
|
14
|
+
const COMMAND_HELP = {
|
|
15
|
+
run: RUN_HELP,
|
|
16
|
+
keys: KEYS_HELP,
|
|
17
|
+
set: SET_HELP,
|
|
18
|
+
unset: UNSET_HELP,
|
|
19
|
+
check: CHECK_HELP,
|
|
20
|
+
policy: POLICY_HELP,
|
|
21
|
+
setup: SETUP_HELP,
|
|
22
|
+
};
|
|
23
|
+
function command(name, handler, ctx) {
|
|
24
|
+
return async (args) => {
|
|
25
|
+
const own = args.includes("--") ? args.slice(0, args.indexOf("--")) : args;
|
|
26
|
+
if (own.includes("-h") || own.includes("--help"))
|
|
27
|
+
return (COMMAND_HELP[name] ?? "").trimEnd();
|
|
28
|
+
return handler(args, ctx);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function formatError(error) {
|
|
32
|
+
const axi = error instanceof AxiError ? error : new AxiError(error instanceof Error ? error.message : String(error), "UNKNOWN");
|
|
33
|
+
return { output: `${renderErrorOutput(axi.message, axi.code, axi.suggestions)}\n`, exitCode: exitCodeForError(axi) };
|
|
34
|
+
}
|
|
35
|
+
export async function main(options = {}) {
|
|
36
|
+
const argv = options.argv ?? process.argv.slice(2);
|
|
37
|
+
// Flags after `--` belong to the child command of `run`, never to secrets-axi.
|
|
38
|
+
const own = argv.includes("--") ? argv.slice(0, argv.indexOf("--")) : argv;
|
|
39
|
+
const json = own.includes("--json");
|
|
40
|
+
setJsonMode(json);
|
|
41
|
+
const stdout = options.stdout ?? process.stdout;
|
|
42
|
+
const ctx = {
|
|
43
|
+
cwd: options.cwd ?? process.cwd(),
|
|
44
|
+
...(options.home !== undefined ? { home: options.home } : {}),
|
|
45
|
+
...(options.userPolicyPath !== undefined ? { userPolicyPath: options.userPolicyPath } : {}),
|
|
46
|
+
...(options.io !== undefined ? { io: options.io } : {}),
|
|
47
|
+
};
|
|
48
|
+
// `run` bypasses the SDK dispatcher: its child's flags (`--help`, `--json`) are not ours, and its output is the child's.
|
|
49
|
+
if (argv[0] === "run" && !json) {
|
|
50
|
+
try {
|
|
51
|
+
const output = own.includes("-h") || own.includes("--help") ? RUN_HELP.trimEnd() : await runCommand(argv.slice(1), ctx);
|
|
52
|
+
if (output)
|
|
53
|
+
stdout.write(`${output}\n`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const { output, exitCode } = formatError(error);
|
|
57
|
+
stdout.write(output);
|
|
58
|
+
process.exitCode = exitCode;
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let buffered = "";
|
|
63
|
+
try {
|
|
64
|
+
await runAxiCli({
|
|
65
|
+
argv: json ? own.filter((a) => a !== "--json") : argv,
|
|
66
|
+
stdout: json ? { write: (chunk) => (buffered += chunk) } : stdout,
|
|
67
|
+
description: DESCRIPTION,
|
|
68
|
+
version: VERSION,
|
|
69
|
+
topLevelHelp: TOP_HELP,
|
|
70
|
+
home: (args) => homeCommand(args, ctx),
|
|
71
|
+
commands: {
|
|
72
|
+
run: command("run", runCommand, ctx),
|
|
73
|
+
keys: command("keys", keysCommand, ctx),
|
|
74
|
+
set: command("set", setCommand, ctx),
|
|
75
|
+
unset: command("unset", unsetCommand, ctx),
|
|
76
|
+
check: command("check", checkCommand, ctx),
|
|
77
|
+
policy: command("policy", policyCommand, ctx),
|
|
78
|
+
setup: command("setup", setupCommand, ctx),
|
|
79
|
+
hook: async () => "The hook entry is `secrets-axi hook` with a PreToolUse JSON object on stdin; agents call it, not people. Run `secrets-axi check '<command>'` for a dry run",
|
|
80
|
+
},
|
|
81
|
+
getCommandHelp: (name) => COMMAND_HELP[name],
|
|
82
|
+
renderUnknownCommand: (name) => `${renderErrorOutput(`unknown command '${name}'`, "VALIDATION_ERROR", [
|
|
83
|
+
"commands: run, keys, set, unset, check, policy, setup (no command = secrets overview)",
|
|
84
|
+
"Run `secrets-axi --help` for examples",
|
|
85
|
+
])}\n`,
|
|
86
|
+
formatError,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
setJsonMode(false);
|
|
91
|
+
}
|
|
92
|
+
if (!json)
|
|
93
|
+
return;
|
|
94
|
+
if (stdout === process.stdout) {
|
|
95
|
+
process.stdout.on("error", (error) => {
|
|
96
|
+
if (error.code !== "EPIPE")
|
|
97
|
+
throw error;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
stdout.write(toJson(buffered));
|
|
101
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { flag, parseArgs } from "../args.js";
|
|
2
|
+
import { usageError } from "../errors.js";
|
|
3
|
+
import { decide } from "../hook/guard.js";
|
|
4
|
+
import { loadPolicy } from "../policy.js";
|
|
5
|
+
import { block, helpBlock, out } from "../render.js";
|
|
6
|
+
export const CHECK_HELP = `usage: secrets-axi check '<shell command>' | --read <path> | --edit <path> | --grep <path|dir>
|
|
7
|
+
Dry run of the Guard: the decision it would make for that tool call, the Policy rule behind it and the Hint the agent would get. Nothing is executed or read.
|
|
8
|
+
examples[3]:
|
|
9
|
+
secrets-axi check 'cat .env | grep DB'
|
|
10
|
+
secrets-axi check 'docker run --env-file .env app'
|
|
11
|
+
secrets-axi check --read ~/.aws/credentials
|
|
12
|
+
`;
|
|
13
|
+
export async function checkCommand(args, ctx) {
|
|
14
|
+
const parsed = parseArgs(args, [{ name: "--read" }, { name: "--edit" }, { name: "--grep" }], "secrets-axi check");
|
|
15
|
+
const read = flag(parsed, "--read");
|
|
16
|
+
const edit = flag(parsed, "--edit");
|
|
17
|
+
const grep = flag(parsed, "--grep");
|
|
18
|
+
const command = parsed.positionals.join(" ");
|
|
19
|
+
const given = [read, edit, grep, command || undefined].filter((v) => v !== undefined).length;
|
|
20
|
+
if (given !== 1)
|
|
21
|
+
throw usageError("give exactly one of: a shell command, --read <path>, --edit <path>, --grep <path>");
|
|
22
|
+
const call = read
|
|
23
|
+
? { tool: "Read", input: { file_path: read }, cwd: ctx.cwd }
|
|
24
|
+
: edit
|
|
25
|
+
? { tool: "Edit", input: { file_path: edit, old_string: "", new_string: "" }, cwd: ctx.cwd }
|
|
26
|
+
: grep
|
|
27
|
+
? { tool: "Grep", input: { pattern: "x", path: grep }, cwd: ctx.cwd }
|
|
28
|
+
: { tool: "Bash", input: { command }, cwd: ctx.cwd };
|
|
29
|
+
const { policy, broken } = loadPolicy(ctx.cwd, { userPath: ctx.userPolicyPath, home: ctx.home });
|
|
30
|
+
const decision = decide(call, policy, { home: ctx.home });
|
|
31
|
+
const base = { tool: call.tool, call: read ?? edit ?? grep ?? command };
|
|
32
|
+
if (decision.kind === "pass") {
|
|
33
|
+
return out(block({ ...base, decision: "pass" }), helpBlock(broken.map((b) => `Policy file ${b} is not valid JSON and was skipped`)));
|
|
34
|
+
}
|
|
35
|
+
if (decision.kind === "deny") {
|
|
36
|
+
return block({ ...base, decision: "deny", rule: decision.rule, ...(decision.file ? { file: decision.file } : {}), hint: decision.hint });
|
|
37
|
+
}
|
|
38
|
+
const rewritten = call.tool === "Bash" ? decision.input["command"] : decision.input["glob"];
|
|
39
|
+
return block({ ...base, decision: "rewrite", rule: decision.rule, rewritten, note: decision.note });
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { expectPositionals, parseArgs } from "../args.js";
|
|
4
|
+
import { axiError, usageError } from "../errors.js";
|
|
5
|
+
import { setKey, unsetKey } from "../env.js";
|
|
6
|
+
import { display } from "../hook/guard.js";
|
|
7
|
+
import { expandHome } from "../policy.js";
|
|
8
|
+
import { block, helpBlock, out } from "../render.js";
|
|
9
|
+
export const SET_HELP = `usage: secrets-axi set <file> KEY=value
|
|
10
|
+
Add or replace one variable in a dotenv file in place; every other line stays. Nothing but the key name is printed. A missing file is created (mode 600).
|
|
11
|
+
examples[2]:
|
|
12
|
+
secrets-axi set .env FEATURE_X=true
|
|
13
|
+
secrets-axi set .env.local DATABASE_URL='postgres://u:p@localhost/db'
|
|
14
|
+
`;
|
|
15
|
+
export const UNSET_HELP = `usage: secrets-axi unset <file> KEY
|
|
16
|
+
Remove every assignment of KEY from a dotenv file in place.
|
|
17
|
+
examples[1]:
|
|
18
|
+
secrets-axi unset .env OLD_TOKEN
|
|
19
|
+
`;
|
|
20
|
+
const KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
21
|
+
export async function setCommand(args, ctx) {
|
|
22
|
+
const parsed = parseArgs(args, [], "secrets-axi set");
|
|
23
|
+
if (parsed.positionals.length > 2)
|
|
24
|
+
throw usageError("expected <file> KEY=value (2 arguments)", ["Quote the pair when the value has spaces: `secrets-axi set .env 'MSG=hello world'`"]);
|
|
25
|
+
expectPositionals(parsed, 2, "secrets-axi set <file> KEY=value");
|
|
26
|
+
const [rawFile, assignment] = parsed.positionals;
|
|
27
|
+
const eq = assignment.indexOf("=");
|
|
28
|
+
if (eq <= 0)
|
|
29
|
+
throw usageError("expected KEY=value", ["Quote the whole pair when the value has spaces: `secrets-axi set .env 'MSG=hello world'`"]);
|
|
30
|
+
const key = assignment.slice(0, eq);
|
|
31
|
+
const value = assignment.slice(eq + 1);
|
|
32
|
+
if (!KEY.test(key))
|
|
33
|
+
throw usageError(`'${key}' is not a variable name`);
|
|
34
|
+
const abs = resolve(ctx.cwd, expandHome(rawFile, ctx.home));
|
|
35
|
+
if (!existsSync(abs)) {
|
|
36
|
+
if (!existsSync(dirname(abs)))
|
|
37
|
+
throw axiError(`directory does not exist: ${dirname(rawFile)}`, "NOT_FOUND");
|
|
38
|
+
closeSync(openSync(abs, "w", 0o600));
|
|
39
|
+
}
|
|
40
|
+
const result = setKey(abs, key, value);
|
|
41
|
+
const file = display(abs, ctx.cwd, ctx.home);
|
|
42
|
+
return out(block({ file, key: result.key, action: result.action, line: result.line }), helpBlock([`Run \`secrets-axi keys ${file}\` to see the variables`]));
|
|
43
|
+
}
|
|
44
|
+
export async function unsetCommand(args, ctx) {
|
|
45
|
+
const parsed = parseArgs(args, [], "secrets-axi unset");
|
|
46
|
+
expectPositionals(parsed, 2, "secrets-axi unset <file> KEY");
|
|
47
|
+
const [rawFile, key] = parsed.positionals;
|
|
48
|
+
if (!KEY.test(key))
|
|
49
|
+
throw usageError(`'${key}' is not a variable name`);
|
|
50
|
+
const abs = resolve(ctx.cwd, expandHome(rawFile, ctx.home));
|
|
51
|
+
if (!existsSync(abs))
|
|
52
|
+
throw axiError(`no such file: ${rawFile}`, "NOT_FOUND");
|
|
53
|
+
const result = unsetKey(abs, key);
|
|
54
|
+
const file = display(abs, ctx.cwd, ctx.home);
|
|
55
|
+
return block({ file, key: result.key, action: result.action, ...(result.line > 0 ? { line: result.line } : {}) });
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseArgs } from "../args.js";
|
|
5
|
+
import { parseEnv } from "../env.js";
|
|
6
|
+
import { loadPolicy } from "../policy.js";
|
|
7
|
+
import { block, helpBlock, out } from "../render.js";
|
|
8
|
+
import { guardStatus } from "../setup/index.js";
|
|
9
|
+
import { detectSource } from "../sources/detect.js";
|
|
10
|
+
import { findEnvFiles, scanSecretFiles } from "./scan.js";
|
|
11
|
+
/** Session-start view: names and counts only, bounded scan, exit 0 anywhere. */
|
|
12
|
+
export async function homeCommand(args, ctx) {
|
|
13
|
+
parseArgs(args, [], "secrets-axi");
|
|
14
|
+
const load = loadPolicy(ctx.cwd, { userPath: ctx.userPolicyPath, home: ctx.home });
|
|
15
|
+
const { policy } = load;
|
|
16
|
+
const scan = scanSecretFiles(ctx.cwd, policy);
|
|
17
|
+
const envFiles = findEnvFiles(ctx.cwd, policy);
|
|
18
|
+
const detected = detectSource(ctx.cwd, policy, envFiles);
|
|
19
|
+
const tracked = trackedByGit(ctx.cwd, scan.files);
|
|
20
|
+
const files = scan.files.map((f) => {
|
|
21
|
+
const count = /\.env|\.envrc|\.secrets/.test(f) ? keyCount(join(ctx.cwd, f)) : undefined;
|
|
22
|
+
return `${f}${count !== undefined ? ` (${count} keys)` : ""}${tracked.has(f) ? " TRACKED BY GIT" : ""}`;
|
|
23
|
+
});
|
|
24
|
+
let guard;
|
|
25
|
+
try {
|
|
26
|
+
const status = guardStatus({ scope: "user", cwd: ctx.cwd, homeDir: ctx.home });
|
|
27
|
+
const on = [status.claude.installed ? "Claude Code" : "", status.codex.installed ? "Codex" : ""].filter(Boolean);
|
|
28
|
+
guard = on.length > 0 ? `active (${on.join(", ")})` : "not installed";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
guard = "unknown";
|
|
32
|
+
}
|
|
33
|
+
const source = detected.kind === "none" ? detected.reason : `${detected.kind} (${detected.reason})${detected.unsupported ? " - not supported yet" : ""}`;
|
|
34
|
+
const help = [
|
|
35
|
+
"Run `secrets-axi run -- <command>` instead of sourcing .env: secrets are injected and masked in the output",
|
|
36
|
+
...(scan.files[0] ? [`Run \`secrets-axi keys ${scan.files.find((f) => f.startsWith(".env")) ?? scan.files[0]}\` for variable names and shapes (never values)`] : []),
|
|
37
|
+
...(guard === "not installed" ? ["Run `secrets-axi setup hooks` to install the Guard for Claude Code and Codex"] : []),
|
|
38
|
+
...(tracked.size > 0 ? [`${[...tracked].join(", ")} is tracked by git: \`git diff\`/\`git log -p\` can print it and the Guard cannot cover that; untrack it`] : []),
|
|
39
|
+
...(load.broken.map((b) => `${b} is not valid JSON and was skipped`)),
|
|
40
|
+
];
|
|
41
|
+
return out(block({
|
|
42
|
+
guard,
|
|
43
|
+
secret_files: files.length > 0 ? files : "none found",
|
|
44
|
+
...(scan.capped ? { scan: "partial (large tree)" } : {}),
|
|
45
|
+
source,
|
|
46
|
+
...(detected.otherEnvFiles.length > 0 ? { not_loaded: detected.otherEnvFiles.join(", ") } : {}),
|
|
47
|
+
...(policy.aws.length > 0 ? { aws: policy.aws.join(", ") } : {}),
|
|
48
|
+
}), helpBlock(help));
|
|
49
|
+
}
|
|
50
|
+
function keyCount(path) {
|
|
51
|
+
try {
|
|
52
|
+
return parseEnv(readFileSync(path, "utf8")).length;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function trackedByGit(cwd, files) {
|
|
59
|
+
if (files.length === 0)
|
|
60
|
+
return new Set();
|
|
61
|
+
const result = spawnSync("git", ["ls-files", "--error-unmatch", "--", ...files], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
62
|
+
if (result.error || result.status === null)
|
|
63
|
+
return new Set();
|
|
64
|
+
return new Set(result.stdout.split("\n").filter(Boolean));
|
|
65
|
+
}
|