@cruxy/cli 0.4.0 → 0.7.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.
Files changed (105) hide show
  1. package/README.md +57 -16
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.d.ts +2 -0
  5. package/dist/agent/loop.js +1 -1
  6. package/dist/agent/prompts.d.ts +2 -2
  7. package/dist/agent/prompts.js +9 -3
  8. package/dist/agent/session.d.ts +24 -0
  9. package/dist/agent/session.js +33 -7
  10. package/dist/approval/classify.d.ts +18 -0
  11. package/dist/approval/classify.js +162 -0
  12. package/dist/approval/index.d.ts +5 -0
  13. package/dist/approval/index.js +5 -0
  14. package/dist/approval/policy.d.ts +37 -0
  15. package/dist/approval/policy.js +81 -0
  16. package/dist/approval/prompt.d.ts +33 -0
  17. package/dist/approval/prompt.js +212 -0
  18. package/dist/approval/service.d.ts +36 -0
  19. package/dist/approval/service.js +37 -0
  20. package/dist/approval/types.d.ts +64 -0
  21. package/dist/approval/types.js +1 -0
  22. package/dist/cli/commands/init.d.ts +7 -0
  23. package/dist/cli/commands/init.js +40 -0
  24. package/dist/cli/commands/login.d.ts +8 -0
  25. package/dist/cli/commands/login.js +36 -0
  26. package/dist/cli/commands/pr.d.ts +8 -0
  27. package/dist/cli/commands/pr.js +87 -0
  28. package/dist/cli/commands/run.js +30 -52
  29. package/dist/cli/onboard.d.ts +25 -0
  30. package/dist/cli/onboard.js +54 -0
  31. package/dist/cli/program.js +21 -1
  32. package/dist/cli/repl.js +10 -1
  33. package/dist/cli/session-factory.d.ts +12 -0
  34. package/dist/cli/session-factory.js +88 -0
  35. package/dist/config/credentials.d.ts +10 -0
  36. package/dist/config/credentials.js +69 -0
  37. package/dist/config/index.d.ts +1 -0
  38. package/dist/config/index.js +1 -0
  39. package/dist/config/manager.d.ts +6 -1
  40. package/dist/config/manager.js +11 -1
  41. package/dist/config/schema.d.ts +48 -9
  42. package/dist/config/schema.js +15 -4
  43. package/dist/constants.d.ts +6 -0
  44. package/dist/constants.js +6 -0
  45. package/dist/errors/constructors.d.ts +35 -0
  46. package/dist/errors/constructors.js +129 -2
  47. package/dist/errors/types.d.ts +8 -0
  48. package/dist/errors/types.js +17 -0
  49. package/dist/onboarding/detect.d.ts +26 -0
  50. package/dist/onboarding/detect.js +56 -0
  51. package/dist/onboarding/flow.d.ts +28 -0
  52. package/dist/onboarding/flow.js +100 -0
  53. package/dist/onboarding/index.d.ts +5 -0
  54. package/dist/onboarding/index.js +5 -0
  55. package/dist/onboarding/io.d.ts +8 -0
  56. package/dist/onboarding/io.js +133 -0
  57. package/dist/onboarding/steps.d.ts +17 -0
  58. package/dist/onboarding/steps.js +100 -0
  59. package/dist/onboarding/types.d.ts +81 -0
  60. package/dist/onboarding/types.js +6 -0
  61. package/dist/plan/approve.d.ts +16 -0
  62. package/dist/plan/approve.js +46 -0
  63. package/dist/plan/execute.d.ts +20 -0
  64. package/dist/plan/execute.js +31 -0
  65. package/dist/plan/index.d.ts +7 -0
  66. package/dist/plan/index.js +7 -0
  67. package/dist/plan/policy.d.ts +26 -0
  68. package/dist/plan/policy.js +45 -0
  69. package/dist/plan/render.d.ts +5 -0
  70. package/dist/plan/render.js +47 -0
  71. package/dist/plan/service.d.ts +39 -0
  72. package/dist/plan/service.js +118 -0
  73. package/dist/plan/submit-plan.d.ts +33 -0
  74. package/dist/plan/submit-plan.js +57 -0
  75. package/dist/plan/types.d.ts +60 -0
  76. package/dist/plan/types.js +6 -0
  77. package/dist/tools/create-pull-request.d.ts +24 -0
  78. package/dist/tools/create-pull-request.js +83 -0
  79. package/dist/tools/file/apply-patch.js +3 -3
  80. package/dist/tools/file/edit-file.js +6 -3
  81. package/dist/tools/file/write-file.js +6 -3
  82. package/dist/tools/index.d.ts +1 -0
  83. package/dist/tools/index.js +1 -0
  84. package/dist/tools/registry.js +2 -0
  85. package/dist/tools/shell/run-command.js +11 -3
  86. package/dist/tools/types.d.ts +25 -6
  87. package/dist/vcs/auth.d.ts +22 -0
  88. package/dist/vcs/auth.js +29 -0
  89. package/dist/vcs/generate.d.ts +72 -0
  90. package/dist/vcs/generate.js +265 -0
  91. package/dist/vcs/git.d.ts +52 -0
  92. package/dist/vcs/git.js +152 -0
  93. package/dist/vcs/github.d.ts +44 -0
  94. package/dist/vcs/github.js +145 -0
  95. package/dist/vcs/guidance.d.ts +20 -0
  96. package/dist/vcs/guidance.js +76 -0
  97. package/dist/vcs/index.d.ts +7 -0
  98. package/dist/vcs/index.js +7 -0
  99. package/dist/vcs/service.d.ts +53 -0
  100. package/dist/vcs/service.js +79 -0
  101. package/dist/vcs/types.d.ts +57 -0
  102. package/dist/vcs/types.js +6 -0
  103. package/package.json +1 -1
  104. package/dist/agent/approval.d.ts +0 -41
  105. package/dist/agent/approval.js +0 -179
package/README.md CHANGED
@@ -16,17 +16,36 @@ an approval gate before anything touches disk.
16
16
  ```bash
17
17
  npm install -g @cruxy/cli
18
18
 
19
- export CRUXY_API_KEY=cxy_live_... # from app.cruxy.in
19
+ cruxy run "explain this codebase" # first run guides you through setup
20
+ ```
21
+
22
+ On your first interactive run with no key, cruxy walks you through getting one,
23
+ validates it, and saves it to `~/.cruxy` (owner-only) — no env var needed. You
24
+ can also run setup on demand:
20
25
 
21
- cruxy run "explain this codebase" # one-shot
22
- cruxy run # interactive session
26
+ ```bash
27
+ cruxy login # set or replace your API key
28
+ cruxy init # key + a project CRUXY.md + a first run
29
+ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
23
30
  ```
24
31
 
25
32
  ## Features
26
33
 
34
+ - **Onboarding** — a guided first run (`cruxy login` / `cruxy init` on demand):
35
+ get a key, validate it live, and save it to `~/.cruxy/credentials.json`
36
+ (`0600`) — never to `config.json`, the project, or env files. Keys resolve
37
+ env → credentials store. Non-interactive runs stay fail-loud
38
+ (`CRUXY_E_AUTH_MISSING_KEY`), never blocking on input.
27
39
  - **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
28
40
  `grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
29
- `list_skills`, `load_skill`.
41
+ `list_skills`, `load_skill`, `create_pull_request`.
42
+ - **Pull requests** — `cruxy pr` (and the `create_pull_request` tool) turn the
43
+ current changes into a PR: feature branch → conventional commit → push → open
44
+ the PR, with a generated title/body, all behind the approval gate. GitHub ships
45
+ first behind a swappable `ForgeProvider`; the token is read from
46
+ `GITHUB_TOKEN` / `GH_TOKEN` / `gh auth token` (never stored). It never commits
47
+ or pushes on a protected branch, never force-pushes, and never bypasses the
48
+ commit/push hooks.
30
49
  - **Codebase index** — a local, incremental semantic index (`cruxy index`)
31
50
  behind the `search_codebase` tool. Embeddings run on-device via fastembed
32
51
  (ONNX, bge-small-en-v1.5) with no network calls; the store is SQLite at
@@ -39,6 +58,11 @@ cruxy run # interactive session
39
58
  validated, and never auto-executed.
40
59
  - **Agent** — streaming output, multi-turn interactive sessions, context
41
60
  compaction, and awareness of git state and project instructions (`CRUXY.md`).
61
+ - **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
62
+ agent proposes a structured, step-by-step plan; you approve it once, then it
63
+ executes with live per-step status. Approving consents to the _shape_ of the
64
+ work — every action still passes the approval gate, and destructive/ungrantable
65
+ actions always re-confirm even after approval.
42
66
  - **Safety** — a single approval gate with diff previews that fails closed;
43
67
  read-only tools never prompt; file access is confined to the project root;
44
68
  shell commands run bounded.
@@ -70,6 +94,22 @@ Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
70
94
  `~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
71
95
  built-in `using-skills` skill for the authoring rules.
72
96
 
97
+ ### Pull requests
98
+
99
+ ```bash
100
+ export GITHUB_TOKEN=ghp_... # or GH_TOKEN, or be logged in with `gh`
101
+
102
+ cruxy pr # generate + open a PR for the current changes
103
+ cruxy pr --base develop # target a specific base branch
104
+ cruxy pr --title "fix(cli): …" # override the generated title
105
+ cruxy pr --draft # open as a draft
106
+ ```
107
+
108
+ The whole plan — branch, commit, and PR title/body — is shown for approval
109
+ before anything runs. On a protected branch (`main`/`master`/configured via
110
+ `git.protectedBranches`) cruxy branches off first; the base defaults to
111
+ `git.defaultBase`, then the repo's default branch, then `main`.
112
+
73
113
  ## Errors & exit codes
74
114
 
75
115
  Every user-facing error prints a title, the cause (when known), concrete next
@@ -78,18 +118,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
78
118
  `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
79
119
  branch on them:
80
120
 
81
- | Exit | Category | Example codes |
82
- | ---- | ---------- | ----------------------------------------------------------------------------------------------- |
83
- | `0` | success | — |
84
- | `1` | internal | `CRUXY_E_INTERNAL` |
85
- | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED` |
86
- | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
87
- | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID` |
88
- | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE` |
89
- | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED` |
90
- | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
91
- | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
92
- | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
121
+ | Exit | Category | Example codes |
122
+ | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `0` | success | — |
124
+ | `1` | internal | `CRUXY_E_INTERNAL` |
125
+ | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT` |
126
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
127
+ | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
128
+ | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
129
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
130
+ | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
131
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
132
+ | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
133
+ | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED` |
93
134
 
94
135
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
95
136
  provider-agnostic, built over `fetch`, with no vendor SDKs.
@@ -1,4 +1,3 @@
1
1
  export * from "./loop.js";
2
2
  export * from "./session.js";
3
3
  export * from "./prompts.js";
4
- export * from "./approval.js";
@@ -1,4 +1,3 @@
1
1
  export * from "./loop.js";
2
2
  export * from "./session.js";
3
3
  export * from "./prompts.js";
4
- export * from "./approval.js";
@@ -30,6 +30,8 @@ export interface RunAgentArgs {
30
30
  } | null;
31
31
  /** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
32
32
  projectInstructions?: string | null;
33
+ /** Plan mode's propose phase (C.31): inject the plan-first system directive. */
34
+ planMode?: boolean;
33
35
  }
34
36
  export interface AgentResult {
35
37
  /** The full conversation, including assistant tool calls and tool results. */
@@ -30,9 +30,9 @@ export async function runAgent(args) {
30
30
  tools: registry
31
31
  .list()
32
32
  .map((tool) => ({ name: tool.name, description: tool.description })),
33
- autoApprove: config.approval.mode === "auto",
34
33
  git: args.git ?? null,
35
34
  projectInstructions: args.projectInstructions ?? null,
35
+ planMode: args.planMode ?? false,
36
36
  });
37
37
  let iterations = 0;
38
38
  for (let i = 0; i < maxIterations; i++) {
@@ -26,10 +26,10 @@ 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;
31
+ /** Plan mode's propose phase (C.31): inject the plan-first directive. */
32
+ planMode?: boolean;
33
33
  }
34
34
  /** Assemble the full system prompt for a session. */
35
35
  export declare function buildSystemPrompt(ctx: PromptContext): string;
@@ -6,6 +6,10 @@
6
6
  * dynamic environment block, so the model always knows where it is, what it
7
7
  * can do, and how it's expected to behave.
8
8
  */
9
+ /** The plan-mode directive, injected during the propose phase only. */
10
+ const PLAN_MODE_SECTION = `## Plan mode
11
+ You are in plan mode. Do NOT edit files, run commands, or take any side-effecting action yet.
12
+ First investigate with the read-only tools if you need to, then call \`submit_plan\` with an ordered list of steps — each with a title, a one-line rationale, and a kind (read | mutate | destructive). Cover the whole task; keep steps concrete and minimal. After you call \`submit_plan\`, stop and end your turn — the user reviews and approves the plan before you execute it.`;
9
13
  /**
10
14
  * The static core of cruxy's behaviour. Phrased as direct instruction to the
11
15
  * model. Keep this tight — every line earns its place; vague prose dilutes the
@@ -69,11 +73,13 @@ function renderTools(tools) {
69
73
  }
70
74
  /** Assemble the full system prompt for a session. */
71
75
  export function buildSystemPrompt(ctx) {
72
- const approval = ctx.autoApprove
73
- ? "Auto-approve is on for this session, so you may proceed without asking — but still pause and confirm before genuinely destructive or irreversible actions."
74
- : "Confirm with the user before taking any destructive or irreversible action.";
76
+ // Every side-effecting action goes through the approval gate (U.3); the user
77
+ // always gets the final say before a mutation or a destructive action.
78
+ const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
75
79
  const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
76
80
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
81
+ if (ctx.planMode)
82
+ sections.push(PLAN_MODE_SECTION);
77
83
  if (ctx.projectInstructions?.trim()) {
78
84
  sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
79
85
  }
@@ -3,6 +3,17 @@ import type { CruxyConfig } from "../config/index.js";
3
3
  import type { ToolContext } from "../tools/index.js";
4
4
  import type { ToolRegistry } from "../tools/index.js";
5
5
  import { type AgentResult } from "./loop.js";
6
+ /**
7
+ * Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
8
+ * the plan package. When plan mode is on, `send` delegates the whole turn to
9
+ * this instead of a single `runAgent` call. Receives the running history (with
10
+ * the user turn already appended) and returns the extended history to adopt.
11
+ */
12
+ export type PlanRunner = (args: {
13
+ messages: Message[];
14
+ projectInstructions: string | null;
15
+ onText?: (delta: string) => void;
16
+ }) => Promise<AgentResult>;
6
17
  export interface SessionArgs {
7
18
  /** A constructed provider to stream from. */
8
19
  provider: Provider;
@@ -19,6 +30,10 @@ export interface SessionArgs {
19
30
  } | null;
20
31
  /** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
21
32
  projectInstructions?: string | null;
33
+ /** Start in plan mode (C.31). Toggleable at runtime via `setPlanMode`. */
34
+ planMode?: boolean;
35
+ /** The plan-mode turn runner; required for plan mode to actually engage. */
36
+ planRunner?: PlanRunner;
22
37
  }
23
38
  /**
24
39
  * Estimate the token footprint of a message list with a cheap chars/4 heuristic
@@ -46,7 +61,16 @@ export declare class Session {
46
61
  private readonly args;
47
62
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
48
63
  private projectInstructions;
64
+ /** Mutable so `/plan` can toggle plan mode mid-session. */
65
+ private planMode;
49
66
  constructor(args: SessionArgs);
67
+ /** Whether plan mode is currently on. */
68
+ getPlanMode(): boolean;
69
+ /**
70
+ * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
71
+ * the session factory); without one, plan mode stays off.
72
+ */
73
+ setPlanMode(enabled: boolean): void;
50
74
  /**
51
75
  * Run one user turn: append the prompt, compact if the history has grown past
52
76
  * the threshold, drive the agent loop over the full history, adopt the
@@ -49,9 +49,25 @@ export class Session {
49
49
  args;
50
50
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
51
51
  projectInstructions;
52
+ /** Mutable so `/plan` can toggle plan mode mid-session. */
53
+ planMode;
52
54
  constructor(args) {
53
55
  this.args = args;
54
56
  this.projectInstructions = args.projectInstructions ?? null;
57
+ // Plan mode requires a wired runner; without one it stays off (no half-on
58
+ // state where the plan directive is injected but nothing orchestrates it).
59
+ this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
60
+ }
61
+ /** Whether plan mode is currently on. */
62
+ getPlanMode() {
63
+ return this.planMode;
64
+ }
65
+ /**
66
+ * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
67
+ * the session factory); without one, plan mode stays off.
68
+ */
69
+ setPlanMode(enabled) {
70
+ this.planMode = enabled && this.args.planRunner !== undefined;
55
71
  }
56
72
  /**
57
73
  * Run one user turn: append the prompt, compact if the history has grown past
@@ -65,13 +81,23 @@ export class Session {
65
81
  this.messages.push({ role: "user", content: userPrompt });
66
82
  // Compact *before* the agent call so the turn runs against a bounded history.
67
83
  await this.maybeCompact();
68
- const result = await runAgent({
69
- messages: this.messages,
70
- ...this.args,
71
- // After the spread so a mid-session `/reload` wins over the initial value.
72
- projectInstructions: this.projectInstructions,
73
- onText,
74
- });
84
+ // Plan mode (C.31) delegates the whole turn to the injected runner: propose a
85
+ // plan, approve/revise, then execute step-by-step. Falls back to the normal
86
+ // single-shot loop when off or unwired, so existing behavior is untouched.
87
+ const result = this.planMode && this.args.planRunner
88
+ ? await this.args.planRunner({
89
+ messages: this.messages,
90
+ projectInstructions: this.projectInstructions,
91
+ onText,
92
+ })
93
+ : await runAgent({
94
+ messages: this.messages,
95
+ ...this.args,
96
+ // After the spread so a mid-session `/reload` wins over the initial value.
97
+ projectInstructions: this.projectInstructions,
98
+ planMode: false, // the plan directive belongs only to the runner's propose phase
99
+ onText,
100
+ });
75
101
  this.messages = result.messages;
76
102
  this.usage.input_tokens += result.usage.input_tokens;
77
103
  this.usage.output_tokens += result.usage.output_tokens;
@@ -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,5 @@
1
+ export * from "./types.js";
2
+ export * from "./classify.js";
3
+ export * from "./policy.js";
4
+ export * from "./prompt.js";
5
+ export * from "./service.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./classify.js";
3
+ export * from "./policy.js";
4
+ export * from "./prompt.js";
5
+ export * from "./service.js";
@@ -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;