@cruxy/cli 0.3.0 → 0.6.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 +46 -1
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.js +2 -2
- package/dist/agent/prompts.d.ts +0 -2
- package/dist/agent/prompts.js +3 -3
- package/dist/approval/classify.d.ts +18 -0
- package/dist/approval/classify.js +162 -0
- package/dist/approval/index.d.ts +5 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/policy.d.ts +37 -0
- package/dist/approval/policy.js +81 -0
- package/dist/approval/prompt.d.ts +33 -0
- package/dist/approval/prompt.js +212 -0
- package/dist/approval/service.d.ts +36 -0
- package/dist/approval/service.js +37 -0
- package/dist/approval/types.d.ts +64 -0
- package/dist/approval/types.js +1 -0
- package/dist/cli/commands/config.js +2 -3
- package/dist/cli/commands/index.js +5 -2
- package/dist/cli/commands/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +31 -23
- package/dist/cli/program.js +26 -4
- package/dist/cli/repl.js +15 -2
- package/dist/config/manager.js +5 -4
- package/dist/config/schema.d.ts +38 -9
- package/dist/config/schema.js +13 -4
- package/dist/errors/boundary.d.ts +43 -0
- package/dist/errors/boundary.js +73 -0
- package/dist/errors/constructors.d.ts +52 -0
- package/dist/errors/constructors.js +329 -0
- package/dist/errors/format.d.ts +31 -0
- package/dist/errors/format.js +60 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.js +4 -0
- package/dist/errors/types.d.ts +79 -0
- package/dist/errors/types.js +118 -0
- package/dist/index.js +8 -5
- package/dist/indexing/embedder.js +3 -3
- package/dist/indexing/service.js +4 -1
- package/dist/skills/loader.d.ts +2 -1
- package/dist/skills/loader.js +0 -0
- package/dist/skills/parser.d.ts +6 -4
- package/dist/skills/parser.js +13 -5
- package/dist/tools/create-pull-request.d.ts +24 -0
- package/dist/tools/create-pull-request.js +83 -0
- package/dist/tools/file/apply-patch.js +3 -3
- package/dist/tools/file/edit-file.js +6 -3
- package/dist/tools/file/paths.d.ts +4 -2
- package/dist/tools/file/paths.js +5 -3
- package/dist/tools/file/write-file.js +6 -3
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/shell/run-command.js +11 -3
- package/dist/tools/types.d.ts +25 -6
- package/dist/vcs/auth.d.ts +22 -0
- package/dist/vcs/auth.js +29 -0
- package/dist/vcs/generate.d.ts +72 -0
- package/dist/vcs/generate.js +265 -0
- package/dist/vcs/git.d.ts +52 -0
- package/dist/vcs/git.js +152 -0
- package/dist/vcs/github.d.ts +44 -0
- package/dist/vcs/github.js +145 -0
- package/dist/vcs/guidance.d.ts +20 -0
- package/dist/vcs/guidance.js +76 -0
- package/dist/vcs/index.d.ts +7 -0
- package/dist/vcs/index.js +7 -0
- package/dist/vcs/service.d.ts +53 -0
- package/dist/vcs/service.js +79 -0
- package/dist/vcs/types.d.ts +57 -0
- package/dist/vcs/types.js +6 -0
- package/package.json +1 -1
- package/dist/agent/approval.d.ts +0 -41
- package/dist/agent/approval.js +0 -179
package/README.md
CHANGED
|
@@ -26,7 +26,14 @@ cruxy run # interactive session
|
|
|
26
26
|
|
|
27
27
|
- **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
|
|
28
28
|
`grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
|
|
29
|
-
`list_skills`, `load_skill`.
|
|
29
|
+
`list_skills`, `load_skill`, `create_pull_request`.
|
|
30
|
+
- **Pull requests** — `cruxy pr` (and the `create_pull_request` tool) turn the
|
|
31
|
+
current changes into a PR: feature branch → conventional commit → push → open
|
|
32
|
+
the PR, with a generated title/body, all behind the approval gate. GitHub ships
|
|
33
|
+
first behind a swappable `ForgeProvider`; the token is read from
|
|
34
|
+
`GITHUB_TOKEN` / `GH_TOKEN` / `gh auth token` (never stored). It never commits
|
|
35
|
+
or pushes on a protected branch, never force-pushes, and never bypasses the
|
|
36
|
+
commit/push hooks.
|
|
30
37
|
- **Codebase index** — a local, incremental semantic index (`cruxy index`)
|
|
31
38
|
behind the `search_codebase` tool. Embeddings run on-device via fastembed
|
|
32
39
|
(ONNX, bge-small-en-v1.5) with no network calls; the store is SQLite at
|
|
@@ -70,6 +77,44 @@ Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
|
|
|
70
77
|
`~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
|
|
71
78
|
built-in `using-skills` skill for the authoring rules.
|
|
72
79
|
|
|
80
|
+
### Pull requests
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export GITHUB_TOKEN=ghp_... # or GH_TOKEN, or be logged in with `gh`
|
|
84
|
+
|
|
85
|
+
cruxy pr # generate + open a PR for the current changes
|
|
86
|
+
cruxy pr --base develop # target a specific base branch
|
|
87
|
+
cruxy pr --title "fix(cli): …" # override the generated title
|
|
88
|
+
cruxy pr --draft # open as a draft
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The whole plan — branch, commit, and PR title/body — is shown for approval
|
|
92
|
+
before anything runs. On a protected branch (`main`/`master`/configured via
|
|
93
|
+
`git.protectedBranches`) cruxy branches off first; the base defaults to
|
|
94
|
+
`git.defaultBase`, then the repo's default branch, then `main`.
|
|
95
|
+
|
|
96
|
+
## Errors & exit codes
|
|
97
|
+
|
|
98
|
+
Every user-facing error prints a title, the cause (when known), concrete next
|
|
99
|
+
steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
|
|
100
|
+
(or `--log-level debug` / `DEBUG=1`) to see the underlying error and stack;
|
|
101
|
+
`NO_COLOR` disables color. Exit codes are stable per category, so scripts can
|
|
102
|
+
branch on them:
|
|
103
|
+
|
|
104
|
+
| Exit | Category | Example codes |
|
|
105
|
+
| ---- | ---------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
106
|
+
| `0` | success | — |
|
|
107
|
+
| `1` | internal | `CRUXY_E_INTERNAL` |
|
|
108
|
+
| `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH` |
|
|
109
|
+
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
|
|
110
|
+
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
|
|
111
|
+
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
|
|
112
|
+
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
|
|
113
|
+
| `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
|
|
114
|
+
| `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
|
|
115
|
+
| `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
|
|
116
|
+
| `10` | approval | `CRUXY_E_APPROVAL_REQUIRED` |
|
|
117
|
+
|
|
73
118
|
The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
|
|
74
119
|
provider-agnostic, built over `fetch`, with no vendor SDKs.
|
|
75
120
|
|
package/dist/agent/index.d.ts
CHANGED
package/dist/agent/index.js
CHANGED
package/dist/agent/loop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { providerUnsupported } from "../errors/index.js";
|
|
1
2
|
import { buildSystemPrompt } from "./prompts.js";
|
|
2
3
|
/**
|
|
3
4
|
* Drive the model/tool loop over an existing conversation. Streams each turn,
|
|
@@ -11,7 +12,7 @@ import { buildSystemPrompt } from "./prompts.js";
|
|
|
11
12
|
export async function runAgent(args) {
|
|
12
13
|
const { provider, registry, config, ctx } = args;
|
|
13
14
|
if (!provider.supportsTools) {
|
|
14
|
-
throw
|
|
15
|
+
throw providerUnsupported(config.model.provider);
|
|
15
16
|
}
|
|
16
17
|
const { logger } = ctx;
|
|
17
18
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
@@ -29,7 +30,6 @@ export async function runAgent(args) {
|
|
|
29
30
|
tools: registry
|
|
30
31
|
.list()
|
|
31
32
|
.map((tool) => ({ name: tool.name, description: tool.description })),
|
|
32
|
-
autoApprove: config.approval.mode === "auto",
|
|
33
33
|
git: args.git ?? null,
|
|
34
34
|
projectInstructions: args.projectInstructions ?? null,
|
|
35
35
|
});
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -26,8 +26,6 @@ export interface PromptContext {
|
|
|
26
26
|
branch: string;
|
|
27
27
|
dirty: boolean;
|
|
28
28
|
} | null;
|
|
29
|
-
/** When true, the agent may act without per-step confirmation. */
|
|
30
|
-
autoApprove: boolean;
|
|
31
29
|
/** Optional extra instructions (e.g. from a project CRUXY.md). */
|
|
32
30
|
projectInstructions?: string | null;
|
|
33
31
|
}
|
package/dist/agent/prompts.js
CHANGED
|
@@ -69,9 +69,9 @@ function renderTools(tools) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Assemble the full system prompt for a session. */
|
|
71
71
|
export function buildSystemPrompt(ctx) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// Every side-effecting action goes through the approval gate (U.3); the user
|
|
73
|
+
// always gets the final say before a mutation or a destructive action.
|
|
74
|
+
const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
|
|
75
75
|
const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
|
|
76
76
|
const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
|
|
77
77
|
if (ctx.projectInstructions?.trim()) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
2
|
+
import type { ApprovalRequest } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Classify a pending tool action into a {@link RiskTier} + a tight session
|
|
5
|
+
* {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
|
|
6
|
+
* (most-restrictive) so a future/unknown action can never slip through as
|
|
7
|
+
* read-only or low-risk.
|
|
8
|
+
*/
|
|
9
|
+
export declare function classify(action: ApproveAction, cwd: string): ApprovalRequest;
|
|
10
|
+
/**
|
|
11
|
+
* Tokenize a command **only if** we can positively prove it is a single, simple
|
|
12
|
+
* invocation (the granted program plus plain args, no shell features). Returns
|
|
13
|
+
* the tokens, or `null` for anything we can't prove safe — deny-by-default. This
|
|
14
|
+
* is what makes a `git` session grant refuse `git push && rm -rf /`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function commandTokens(command: string): string[] | null;
|
|
17
|
+
/** Is `target` the root itself or a descendant of it? (Also true if root is a file == target.) */
|
|
18
|
+
export declare function isInside(root: string, target: string): boolean;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Classify a pending tool action into a {@link RiskTier} + a tight session
|
|
4
|
+
* {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
|
|
5
|
+
* (most-restrictive) so a future/unknown action can never slip through as
|
|
6
|
+
* read-only or low-risk.
|
|
7
|
+
*/
|
|
8
|
+
export function classify(action, cwd) {
|
|
9
|
+
const root = path.resolve(cwd);
|
|
10
|
+
switch (action.kind) {
|
|
11
|
+
case "write":
|
|
12
|
+
case "edit":
|
|
13
|
+
return fileRequest(action, "mutate", root);
|
|
14
|
+
case "patch":
|
|
15
|
+
// A patch that deletes files is destructive; create/update-only is mutate.
|
|
16
|
+
return fileRequest(action, patchHasDelete(action) ? "destructive" : "mutate", root);
|
|
17
|
+
case "shell":
|
|
18
|
+
return shellRequest(action, root);
|
|
19
|
+
case "vcs":
|
|
20
|
+
return vcsRequest(action, root);
|
|
21
|
+
default:
|
|
22
|
+
return {
|
|
23
|
+
action,
|
|
24
|
+
tier: "destructive",
|
|
25
|
+
scope: { kind: "none" },
|
|
26
|
+
summary: `perform an unrecognized action (${describeKind(action)})`,
|
|
27
|
+
targets: [],
|
|
28
|
+
cwd: root,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ── shell ─────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Any shell control/expansion character means we cannot prove the command is a
|
|
34
|
+
// single simple invocation. Covers && || ; | ` $ ( ) { } * ? ~ ! # ' " \ < > and
|
|
35
|
+
// newlines (so >> and & background are caught by > and & too).
|
|
36
|
+
const SHELL_META = /[&|;<>`$(){}*?~!#'"\\\n\r]/;
|
|
37
|
+
// A token we positively accept as a simple argument — letters, digits, and a
|
|
38
|
+
// small set of punctuation that has no shell meaning in an unquoted word.
|
|
39
|
+
const SAFE_ARG = /^[A-Za-z0-9_./=,:+@%-]+$/;
|
|
40
|
+
/**
|
|
41
|
+
* Tokenize a command **only if** we can positively prove it is a single, simple
|
|
42
|
+
* invocation (the granted program plus plain args, no shell features). Returns
|
|
43
|
+
* the tokens, or `null` for anything we can't prove safe — deny-by-default. This
|
|
44
|
+
* is what makes a `git` session grant refuse `git push && rm -rf /`.
|
|
45
|
+
*/
|
|
46
|
+
export function commandTokens(command) {
|
|
47
|
+
const trimmed = command.trim();
|
|
48
|
+
if (trimmed === "" || SHELL_META.test(trimmed))
|
|
49
|
+
return null;
|
|
50
|
+
const tokens = trimmed.split(/\s+/);
|
|
51
|
+
return tokens.every((t) => SAFE_ARG.test(t)) ? tokens : null;
|
|
52
|
+
}
|
|
53
|
+
function shellRequest(action, root) {
|
|
54
|
+
const command = action.command ?? "";
|
|
55
|
+
// A session grant is only offered when the command is provably simple; its
|
|
56
|
+
// scope is the program token. Complex commands get `none` (approve-once only).
|
|
57
|
+
const tokens = commandTokens(command);
|
|
58
|
+
const scope = tokens
|
|
59
|
+
? { kind: "shell-prefix", token: tokens[0] }
|
|
60
|
+
: { kind: "none" };
|
|
61
|
+
return {
|
|
62
|
+
action,
|
|
63
|
+
tier: "destructive",
|
|
64
|
+
scope,
|
|
65
|
+
summary: `run: ${command}`,
|
|
66
|
+
targets: [],
|
|
67
|
+
cwd: root,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ── vcs (open pull request) ─────────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* A pull-request publish (C.15): branch → commit → push → open PR. Always
|
|
73
|
+
* `destructive` (it pushes under the user's identity) and never session-grantable
|
|
74
|
+
* — scope `none`, so every PR is approved on its own. The preview carries the
|
|
75
|
+
* full plan; the summary names the PR.
|
|
76
|
+
*/
|
|
77
|
+
function vcsRequest(action, root) {
|
|
78
|
+
const title = action.preview?.type === "pr" ? action.preview.prTitle : "a pull request";
|
|
79
|
+
return {
|
|
80
|
+
action,
|
|
81
|
+
tier: "destructive",
|
|
82
|
+
scope: { kind: "none" },
|
|
83
|
+
summary: `open PR: ${title}`,
|
|
84
|
+
targets: [],
|
|
85
|
+
cwd: root,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ── file (write / edit / patch) ────────────────────────────────────────────────
|
|
89
|
+
function fileRequest(action, tier, root) {
|
|
90
|
+
const targets = fileTargets(action, root);
|
|
91
|
+
return {
|
|
92
|
+
action,
|
|
93
|
+
tier,
|
|
94
|
+
scope: fileScope(targets, root),
|
|
95
|
+
summary: fileSummary(action, targets, root),
|
|
96
|
+
targets,
|
|
97
|
+
cwd: root,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Absolute target paths for a file action (write/edit: one; patch: many). */
|
|
101
|
+
function fileTargets(action, root) {
|
|
102
|
+
if (action.kind === "write" || action.kind === "edit") {
|
|
103
|
+
return action.path ? [path.resolve(root, action.path)] : [];
|
|
104
|
+
}
|
|
105
|
+
if (action.kind === "patch" && action.preview?.type === "patch") {
|
|
106
|
+
return action.preview.files.map((f) => path.resolve(root, f.path));
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The tight subtree a file grant covers: the common parent directory of the
|
|
112
|
+
* targets. **Root-cap:** if that parent is the project root, never grant the
|
|
113
|
+
* whole project — narrow to the exact single file, or refuse (scope `none`) for
|
|
114
|
+
* a multi-file action spanning the root.
|
|
115
|
+
*/
|
|
116
|
+
function fileScope(targets, root) {
|
|
117
|
+
if (targets.length === 0)
|
|
118
|
+
return { kind: "none" };
|
|
119
|
+
const dir = commonDir(targets);
|
|
120
|
+
if (dir === root || !isInside(root, dir)) {
|
|
121
|
+
return targets.length === 1
|
|
122
|
+
? { kind: "file-subtree", root: targets[0] } // exact-file grant
|
|
123
|
+
: { kind: "none" };
|
|
124
|
+
}
|
|
125
|
+
return { kind: "file-subtree", root: dir };
|
|
126
|
+
}
|
|
127
|
+
function fileSummary(action, targets, root) {
|
|
128
|
+
const verb = action.kind === "write"
|
|
129
|
+
? "write"
|
|
130
|
+
: action.kind === "edit"
|
|
131
|
+
? "edit"
|
|
132
|
+
: "patch";
|
|
133
|
+
if (targets.length === 1)
|
|
134
|
+
return `${verb}: ${path.relative(root, targets[0]) || targets[0]}`;
|
|
135
|
+
return `${verb}: ${targets.length} files`;
|
|
136
|
+
}
|
|
137
|
+
function patchHasDelete(action) {
|
|
138
|
+
return (action.preview?.type === "patch" &&
|
|
139
|
+
action.preview.files.some((f) => f.op === "delete"));
|
|
140
|
+
}
|
|
141
|
+
// ── shared path helpers ────────────────────────────────────────────────────────
|
|
142
|
+
/** Is `target` the root itself or a descendant of it? (Also true if root is a file == target.) */
|
|
143
|
+
export function isInside(root, target) {
|
|
144
|
+
return target === root || target.startsWith(root + path.sep);
|
|
145
|
+
}
|
|
146
|
+
/** The deepest directory that contains every path. */
|
|
147
|
+
function commonDir(paths) {
|
|
148
|
+
let common = path.dirname(paths[0]);
|
|
149
|
+
for (const p of paths.slice(1)) {
|
|
150
|
+
const dir = path.dirname(p);
|
|
151
|
+
while (!isInside(common, dir)) {
|
|
152
|
+
const parent = path.dirname(common);
|
|
153
|
+
if (parent === common)
|
|
154
|
+
return common; // filesystem root
|
|
155
|
+
common = parent;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return common;
|
|
159
|
+
}
|
|
160
|
+
function describeKind(action) {
|
|
161
|
+
return String(action.kind ?? "unknown");
|
|
162
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type PromptIO } from "./prompt.js";
|
|
2
|
+
import type { ApprovalDecision, ApprovalPolicy, ApprovalRequest, Scope } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory, per-session allowlist. Grants are **scoped** (a command prefix
|
|
5
|
+
* or a path subtree) and **tier-keyed** (a `mutate` grant can never cover a
|
|
6
|
+
* `destructive` action), **never blanket**, and **never persisted**. A new
|
|
7
|
+
* session ⇒ a new allowlist (it is owned by the per-session ApprovalService).
|
|
8
|
+
*/
|
|
9
|
+
export declare class SessionAllowlist {
|
|
10
|
+
private grants;
|
|
11
|
+
/** Record a session grant for `request`'s scope. No-op when nothing is safe to grant. */
|
|
12
|
+
grant(request: ApprovalRequest): void;
|
|
13
|
+
/** Does an existing grant cover `request`? Requires same tier **and** scope match. */
|
|
14
|
+
allows(request: ApprovalRequest): boolean;
|
|
15
|
+
clear(): void;
|
|
16
|
+
size(): number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether `scope` covers `request`. Shell: the command must be *provably simple*
|
|
20
|
+
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
21
|
+
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
22
|
+
* resolve inside the granted subtree.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scopeCovers(scope: Exclude<Scope, {
|
|
25
|
+
kind: "none";
|
|
26
|
+
}>, request: ApprovalRequest): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The default policy: consult the allowlist, otherwise prompt. On "allow this
|
|
29
|
+
* session" it records the scoped grant; a rejection carries optional feedback
|
|
30
|
+
* back to the agent.
|
|
31
|
+
*/
|
|
32
|
+
export declare class InteractivePolicy implements ApprovalPolicy {
|
|
33
|
+
private readonly allowlist;
|
|
34
|
+
private readonly io;
|
|
35
|
+
constructor(allowlist: SessionAllowlist, io: PromptIO);
|
|
36
|
+
decide(request: ApprovalRequest): Promise<ApprovalDecision>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { commandTokens, isInside } from "./classify.js";
|
|
2
|
+
import { promptForApproval } from "./prompt.js";
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory, per-session allowlist. Grants are **scoped** (a command prefix
|
|
5
|
+
* or a path subtree) and **tier-keyed** (a `mutate` grant can never cover a
|
|
6
|
+
* `destructive` action), **never blanket**, and **never persisted**. A new
|
|
7
|
+
* session ⇒ a new allowlist (it is owned by the per-session ApprovalService).
|
|
8
|
+
*/
|
|
9
|
+
export class SessionAllowlist {
|
|
10
|
+
grants = [];
|
|
11
|
+
/** Record a session grant for `request`'s scope. No-op when nothing is safe to grant. */
|
|
12
|
+
grant(request) {
|
|
13
|
+
if (request.scope.kind === "none")
|
|
14
|
+
return;
|
|
15
|
+
this.grants.push({ tier: request.tier, scope: request.scope });
|
|
16
|
+
}
|
|
17
|
+
/** Does an existing grant cover `request`? Requires same tier **and** scope match. */
|
|
18
|
+
allows(request) {
|
|
19
|
+
return this.grants.some((g) => g.tier === request.tier && scopeCovers(g.scope, request));
|
|
20
|
+
}
|
|
21
|
+
clear() {
|
|
22
|
+
this.grants = [];
|
|
23
|
+
}
|
|
24
|
+
size() {
|
|
25
|
+
return this.grants.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether `scope` covers `request`. Shell: the command must be *provably simple*
|
|
30
|
+
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
31
|
+
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
32
|
+
* resolve inside the granted subtree.
|
|
33
|
+
*/
|
|
34
|
+
export function scopeCovers(scope, request) {
|
|
35
|
+
if (scope.kind === "shell-prefix") {
|
|
36
|
+
if (request.action.kind !== "shell")
|
|
37
|
+
return false;
|
|
38
|
+
const tokens = commandTokens(request.action.command ?? "");
|
|
39
|
+
return tokens !== null && tokens[0] === scope.token;
|
|
40
|
+
}
|
|
41
|
+
// file-subtree
|
|
42
|
+
return (request.targets.length > 0 &&
|
|
43
|
+
request.targets.every((t) => isInside(scope.root, t)));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The default policy: consult the allowlist, otherwise prompt. On "allow this
|
|
47
|
+
* session" it records the scoped grant; a rejection carries optional feedback
|
|
48
|
+
* back to the agent.
|
|
49
|
+
*/
|
|
50
|
+
export class InteractivePolicy {
|
|
51
|
+
allowlist;
|
|
52
|
+
io;
|
|
53
|
+
constructor(allowlist, io) {
|
|
54
|
+
this.allowlist = allowlist;
|
|
55
|
+
this.io = io;
|
|
56
|
+
}
|
|
57
|
+
async decide(request) {
|
|
58
|
+
if (this.allowlist.allows(request))
|
|
59
|
+
return { allow: true };
|
|
60
|
+
const choice = await promptForApproval(request, this.io);
|
|
61
|
+
switch (choice.kind) {
|
|
62
|
+
case "once":
|
|
63
|
+
return { allow: true };
|
|
64
|
+
case "session":
|
|
65
|
+
this.allowlist.grant(request);
|
|
66
|
+
return { allow: true };
|
|
67
|
+
case "reject":
|
|
68
|
+
return choice.reason
|
|
69
|
+
? {
|
|
70
|
+
allow: false,
|
|
71
|
+
feedback: `The user rejected this action. Reason: ${choice.reason}`,
|
|
72
|
+
}
|
|
73
|
+
: { allow: false };
|
|
74
|
+
case "instruct":
|
|
75
|
+
return {
|
|
76
|
+
allow: false,
|
|
77
|
+
feedback: `The user rejected this action and asked you to do this instead: ${choice.instruction}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ApprovalRequest } from "./types.js";
|
|
2
|
+
/** The four user choices (plus the implicit default-deny). */
|
|
3
|
+
export type PromptChoice = {
|
|
4
|
+
kind: "once";
|
|
5
|
+
} | {
|
|
6
|
+
kind: "session";
|
|
7
|
+
} | {
|
|
8
|
+
kind: "reject";
|
|
9
|
+
reason?: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "instruct";
|
|
12
|
+
instruction: string;
|
|
13
|
+
};
|
|
14
|
+
/** I/O surface for the prompt — injectable so tests script the keys/lines. */
|
|
15
|
+
export interface PromptIO {
|
|
16
|
+
write(text: string): void;
|
|
17
|
+
/** Read a single keypress; resolves "" on EOF / Ctrl-C. */
|
|
18
|
+
readKey(): Promise<string>;
|
|
19
|
+
/** Read one line (no trailing newline); resolves "" on EOF. */
|
|
20
|
+
readLine(): Promise<string>;
|
|
21
|
+
/** Whether to emit ANSI color. */
|
|
22
|
+
color: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
26
|
+
* read a follow-up line (reason / instruction). Anything else — including EOF —
|
|
27
|
+
* is a reject.
|
|
28
|
+
*/
|
|
29
|
+
export declare function promptForApproval(request: ApprovalRequest, io: PromptIO): Promise<PromptChoice>;
|
|
30
|
+
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
31
|
+
export declare function render(request: ApprovalRequest, color: boolean): string;
|
|
32
|
+
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
33
|
+
export declare function defaultPromptIO(color: boolean): PromptIO;
|