@axplusb/kepler 2.0.6 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axplusb/kepler",
3
- "version": "2.0.6",
3
+ "version": "2.2.0",
4
4
  "description": "Kepler — AI coding agent with operating brief, preflight planning, and sub-agents. SWE-bench Lite evaluated.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "readme": "KEPLER-README.md",
15
15
  "scripts": {
16
16
  "start": "node src/terminal/main.mjs",
17
- "test": "node test/test-sse-client.mjs && node test/test-tool-executor.mjs && node test/test-project-artifacts.mjs && node test/test-skills.mjs && node test/test-callback.mjs && node test/test-formatter.mjs && node test/test-terminal-rendering.mjs && node test/test-slash-commands.mjs && node test/test-approval.mjs && node test/test-session-manager.mjs && node test/test-safety.mjs && node test/test-jsonl-writer.mjs && node test/test-analytics.mjs && node test/test-stagnation.mjs"
17
+ "test": "KEPLER_HOME=/tmp/kepler-test-home node test/test-sse-client.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-tool-executor.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-project-artifacts.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-skills.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-callback.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-rate-limit-display.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-preflight.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-formatter.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-terminal-rendering.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-slash-commands.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-approval.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-approval-log.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-kepler-contract.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-session-manager.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-safety.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-jsonl-writer.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-analytics.mjs && KEPLER_HOME=/tmp/kepler-test-home node test/test-stagnation.mjs"
18
18
  },
19
19
  "engines": {
20
20
  "node": ">=18.0.0"
@@ -0,0 +1,100 @@
1
+ import { execFile } from 'node:child_process';
2
+ import * as path from 'node:path';
3
+ import { loadKeplerSettings } from './settings-loader.mjs';
4
+
5
+ function asArray(value) {
6
+ if (!value) return [];
7
+ return Array.isArray(value) ? value : [value];
8
+ }
9
+
10
+ function matchesHook(hook, toolName) {
11
+ const matcher = hook.matcher || hook.toolName || hook.tool;
12
+ if (!matcher) return true;
13
+ try {
14
+ return new RegExp(matcher).test(toolName);
15
+ } catch {
16
+ return matcher === toolName;
17
+ }
18
+ }
19
+
20
+ function runCommand(command, { cwd, env, timeoutMs, input }) {
21
+ return new Promise((resolve) => {
22
+ const child = execFile('/bin/sh', ['-lc', command], {
23
+ cwd,
24
+ env,
25
+ timeout: timeoutMs,
26
+ windowsHide: true,
27
+ }, (error, stdout, stderr) => {
28
+ let parsed = null;
29
+ try { parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null; }
30
+ catch { parsed = stdout.trim() ? { output: stdout.trim() } : null; }
31
+ resolve({
32
+ ok: !error,
33
+ code: error?.code ?? 0,
34
+ signal: error?.signal || null,
35
+ stdout,
36
+ stderr,
37
+ parsed,
38
+ error,
39
+ });
40
+ });
41
+ if (input) child.stdin?.end(JSON.stringify(input));
42
+ });
43
+ }
44
+
45
+ export class HookRunner {
46
+ constructor({ cwd = process.cwd(), settings = null, sessionId = null } = {}) {
47
+ this.cwd = cwd;
48
+ this.sessionId = sessionId;
49
+ this.settings = settings || loadKeplerSettings({ cwd }).settings;
50
+ }
51
+
52
+ reload() {
53
+ this.settings = loadKeplerSettings({ cwd: this.cwd }).settings;
54
+ }
55
+
56
+ hooksFor(event) {
57
+ return asArray(this.settings?.hooks?.[event]);
58
+ }
59
+
60
+ async run(event, payload = {}) {
61
+ const results = [];
62
+ for (const hook of this.hooksFor(event)) {
63
+ const toolName = payload.toolName || payload.tool || '';
64
+ if ((event === 'PreToolUse' || event === 'PostToolUse') && !matchesHook(hook, toolName)) continue;
65
+ const timeoutSeconds = hook.timeout ?? this.settings?.hooks?.timeout ?? 5;
66
+ const input = {
67
+ event,
68
+ tool_name: toolName,
69
+ tool_input: payload.input || payload.args || {},
70
+ tool_result: payload.result || null,
71
+ project_dir: this.cwd,
72
+ session_id: this.sessionId || '',
73
+ };
74
+ const env = {
75
+ ...process.env,
76
+ ...(this.settings?.env || {}),
77
+ KEPLER_TOOL_NAME: toolName,
78
+ KEPLER_TOOL_INPUT_FILE_PATH: input.tool_input.file_path || input.tool_input.path || '',
79
+ KEPLER_PROJECT_DIR: this.cwd,
80
+ KEPLER_SESSION_ID: this.sessionId || '',
81
+ KEPLER_TURN_ID: payload.turnId || '',
82
+ };
83
+ const result = await runCommand(hook.command, {
84
+ cwd: path.resolve(this.cwd),
85
+ env,
86
+ timeoutMs: timeoutSeconds * 1000,
87
+ input,
88
+ });
89
+ results.push({ hook, ...result });
90
+ if (result.code === 2 || result.parsed?.block === true || result.parsed?.decision === 'deny') {
91
+ return {
92
+ blocked: true,
93
+ message: result.parsed?.message || result.stderr?.trim() || `Blocked by hook: ${hook.command}`,
94
+ results,
95
+ };
96
+ }
97
+ }
98
+ return { blocked: false, results };
99
+ }
100
+ }
@@ -0,0 +1,32 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+
5
+ function readIfExists(filePath, maxChars = 12000) {
6
+ try {
7
+ if (!fs.existsSync(filePath)) return null;
8
+ const content = fs.readFileSync(filePath, 'utf-8');
9
+ return {
10
+ path: filePath,
11
+ content: content.length > maxChars
12
+ ? content.slice(0, maxChars) + '\n\n[...truncated...]'
13
+ : content,
14
+ };
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function loadKeplerMemory({ cwd = process.cwd() } = {}) {
21
+ const files = [];
22
+ const global = readIfExists(path.join(os.homedir(), '.kepler', 'KEPLER.md'));
23
+ if (global) files.push({ source: 'global', ...global });
24
+
25
+ const topLevel = readIfExists(path.join(cwd, 'KEPLER.md'));
26
+ if (topLevel) files.push({ source: 'project-top-level', ...topLevel });
27
+
28
+ const project = readIfExists(path.join(cwd, '.kepler', 'KEPLER.md'));
29
+ if (project) files.push({ source: 'project', ...project });
30
+
31
+ return files;
32
+ }
@@ -0,0 +1,45 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { deepMerge } from '../core/policy-resolver.mjs';
4
+
5
+ export const DEFAULT_KEPLER_SETTINGS = Object.freeze({
6
+ env: {},
7
+ permissions: {
8
+ shellAllowlist: [],
9
+ editDenylist: [],
10
+ },
11
+ hooks: {
12
+ UserPromptSubmit: [],
13
+ PreToolUse: [],
14
+ PostToolUse: [],
15
+ Stop: [],
16
+ },
17
+ });
18
+
19
+ function readJson(filePath) {
20
+ try {
21
+ if (!fs.existsSync(filePath)) return null;
22
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
23
+ } catch (err) {
24
+ return { __error: err.message };
25
+ }
26
+ }
27
+
28
+ export function loadKeplerSettings({ cwd = process.cwd() } = {}) {
29
+ const base = path.join(cwd, '.kepler');
30
+ const layers = [
31
+ { name: 'default', path: null, data: DEFAULT_KEPLER_SETTINGS },
32
+ ];
33
+ for (const [name, file] of [
34
+ ['project', path.join(base, 'settings.json')],
35
+ ['local', path.join(base, 'settings.local.json')],
36
+ ]) {
37
+ const data = readJson(file);
38
+ if (data && !data.__error) layers.push({ name, path: file, data });
39
+ else if (data?.__error) layers.push({ name, path: file, error: data.__error, data: {} });
40
+ }
41
+
42
+ let settings = {};
43
+ for (const layer of layers) settings = deepMerge(settings, layer.data || {});
44
+ return { settings, layers };
45
+ }
@@ -0,0 +1,104 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ const REDACTED = 'REDACTED';
5
+ const SENSITIVE_KEY_RE = /^(?:authorization|api[-_]?key|apikey|key|token|access[-_]?token|refresh[-_]?token|secret|password|passwd|pwd|access[-_]?key|secret[-_]?access[-_]?key)$|(?:^|[-_])(?:api[-_]?key|apikey|token|secret|password|passwd|pwd|access[-_]?key|secret[-_]?access[-_]?key)(?:$|[-_])/i;
6
+ const SENSITIVE_ASSIGNMENT_KEY = String.raw`[A-Z0-9_-]*(?:API[-_]?KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|ACCESS[-_]?KEY|SECRET[-_]?ACCESS[-_]?KEY)[A-Z0-9_-]*`;
7
+ const SENSITIVE_JSON_KEY = String.raw`(?:authorization|api[-_]?key|apikey|key|token|access[-_]?token|refresh[-_]?token|secret|password|passwd|pwd|access[-_]?key|secret[-_]?access[-_]?key|[A-Z0-9_-]*(?:API[-_]?KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|ACCESS[-_]?KEY|SECRET[-_]?ACCESS[-_]?KEY)[A-Z0-9_-]*)`;
8
+
9
+ export function redactSensitive(str) {
10
+ let s = String(str ?? '');
11
+
12
+ // Authorization headers in shell commands, curl args, and copied HTTP snippets.
13
+ s = s.replace(/(Authorization\s*:\s*(?:Bearer|Basic)\s+)(?:"[^"]*"|'[^']*'|[^\s"',;]+)/gi, `$1${REDACTED}`);
14
+
15
+ // Secret-bearing query/form parameters.
16
+ s = s.replace(/([?&](?:api_key|apikey|api-key|key|token|access_token|refresh_token|password|secret)=)([^&\s"']+)/gi, `$1${REDACTED}`);
17
+
18
+ // Env-style assignments, including quoted values.
19
+ s = s.replace(
20
+ new RegExp(`(^|[\\s;,])(${SENSITIVE_ASSIGNMENT_KEY}\\s*=\\s*)(?:"[^"]*"|'[^']*'|[^\\s"',;]+)`, 'gi'),
21
+ (_match, prefix, key) => `${prefix}${key}${REDACTED}`
22
+ );
23
+
24
+ // JSON fragments that arrive as strings rather than structured objects.
25
+ s = s.replace(
26
+ new RegExp(`("(?:${SENSITIVE_JSON_KEY})"\\s*:\\s*)(?:"(?:\\\\.|[^"\\\\])*"|[^,}\\s]+)`, 'gi'),
27
+ `$1"${REDACTED}"`
28
+ );
29
+
30
+ return s;
31
+ }
32
+
33
+ function isSensitiveKey(key) {
34
+ return SENSITIVE_KEY_RE.test(String(key || ''));
35
+ }
36
+
37
+ function sanitizeForLog(value, depth = 0) {
38
+ if (depth > 12) return '[MaxDepth]';
39
+ if (typeof value === 'string') return redactSensitive(value);
40
+ if (value == null || typeof value !== 'object') return value;
41
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeForLog(item, depth + 1));
42
+
43
+ const out = {};
44
+ for (const [key, item] of Object.entries(value)) {
45
+ out[key] = isSensitiveKey(key) ? REDACTED : sanitizeForLog(item, depth + 1);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function safeArgs(args) {
51
+ if (args?.command) return redactSensitive(String(args.command)).slice(0, 500);
52
+ if (args?.file_path || args?.path) return redactSensitive(String(args.file_path || args.path)).slice(0, 500);
53
+ try { return JSON.stringify(sanitizeForLog(args || {})).slice(0, 500); }
54
+ catch { return redactSensitive(String(args || '')).slice(0, 500); }
55
+ }
56
+
57
+ function safeText(value, max = 500) {
58
+ if (!value) return undefined;
59
+ return redactSensitive(String(value)).slice(0, max);
60
+ }
61
+
62
+ export class ApprovalLog {
63
+ constructor({ cwd = process.cwd() } = {}) {
64
+ this.cwd = cwd;
65
+ this.filePath = path.join(cwd, '.kepler', 'approvals.log');
66
+ }
67
+
68
+ append(entry) {
69
+ try {
70
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
71
+ const line = JSON.stringify({
72
+ ts: new Date().toISOString(),
73
+ tier: entry.tier,
74
+ tool: entry.tool,
75
+ args: safeArgs(entry.args),
76
+ decision: entry.decision,
77
+ scope: entry.scope || 'once',
78
+ rule_id: entry.rule_id || null,
79
+ reason: safeText(entry.reason),
80
+ });
81
+ fs.appendFileSync(this.filePath, line + '\n', { mode: 0o600 });
82
+ try { fs.chmodSync(this.filePath, 0o600); } catch {}
83
+ } catch { /* approval logging must not block execution */ }
84
+ }
85
+
86
+ readRecent(limit = 20) {
87
+ try {
88
+ if (!fs.existsSync(this.filePath)) return [];
89
+ return fs.readFileSync(this.filePath, 'utf-8')
90
+ .trim()
91
+ .split('\n')
92
+ .filter(Boolean)
93
+ .slice(-limit)
94
+ .map(line => {
95
+ try { return JSON.parse(line); }
96
+ catch { return null; }
97
+ })
98
+ .filter(Boolean)
99
+ .reverse();
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+ }
@@ -20,9 +20,12 @@ import {
20
20
  } from './risk-tier.mjs';
21
21
  import {
22
22
  renderApprovalPrompt,
23
- renderInlinePrompt,
23
+ renderTrustedApproval,
24
24
  defaultOptions as approvalOptions,
25
25
  } from '../ui/approval.mjs';
26
+ import { ApprovalLog } from './approval-log.mjs';
27
+ import { TrustStore } from './trust.mjs';
28
+ import { loadEffectivePolicy } from './policy-resolver.mjs';
26
29
 
27
30
  // ── Tool Classification ──
28
31
  //
@@ -36,6 +39,8 @@ const WRITE_TOOLS = new Set([
36
39
 
37
40
  function defaultWhy(tier, tool, args) {
38
41
  switch (tier) {
42
+ case TIERS.SENSITIVE_READ:
43
+ return `Reads a sensitive path (${toolDisplaySummary(tool, args) || 'secret-like file'}). Confirm before exposing its contents to the agent.`;
39
44
  case TIERS.SHELL_DANGEROUS:
40
45
  return `Shell command matches a high-risk pattern (rm -rf, sudo, force push, etc.). Confirm before running.`;
41
46
  case TIERS.DESTRUCTIVE:
@@ -66,12 +71,17 @@ const write = (s) => process.stderr.write(s);
66
71
  // ── Approval Manager ──
67
72
 
68
73
  export class ApprovalManager {
69
- constructor({ autoApprove = false, planMode = false } = {}) {
74
+ constructor({ autoApprove = false, planMode = false, cwd = process.cwd(), policy = null, trustStore = null, approvalLog = null } = {}) {
70
75
  this.autoApprove = autoApprove;
71
76
  this.planMode = planMode;
77
+ this.cwd = cwd;
78
+ this.policy = policy || loadEffectivePolicy({ cwd }).policy;
79
+ this.trustStore = trustStore || new TrustStore({ cwd, policy: this.policy });
80
+ this.approvalLog = approvalLog || new ApprovalLog({ cwd });
72
81
  this.approveAll = false;
73
82
  this.approvedToolTypes = new Set();
74
83
  this.history = [];
84
+ this.rejectionHints = [];
75
85
  this._rl = null;
76
86
  }
77
87
 
@@ -86,7 +96,8 @@ export class ApprovalManager {
86
96
  const wasActive = this.approveAll || this.approvedToolTypes.size > 0;
87
97
  this.approveAll = false;
88
98
  this.approvedToolTypes.clear();
89
- return wasActive;
99
+ const trustActive = this.trustStore?.revoke?.() || false;
100
+ return wasActive || trustActive;
90
101
  }
91
102
 
92
103
  getModeLabel() {
@@ -122,14 +133,32 @@ export class ApprovalManager {
122
133
  return { approved: true, tier, requireCheckpoint: true };
123
134
  }
124
135
 
136
+ const trust = this.trustStore?.find?.(toolName, args, tier);
137
+ if (trust?.decision === 'deny') {
138
+ this.history.push({ tool: toolName, decision: 'trusted-deny', tier, time: Date.now(), rule_id: trust.rule?.id });
139
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'deny_trusted', scope: trust.rule?.scope, rule_id: trust.rule?.id });
140
+ return { approved: false, tier, reason: `Denied by trust rule ${trust.rule?.id || ''}`.trim() };
141
+ }
142
+ if (trust?.decision === 'allow') {
143
+ this.history.push({ tool: toolName, decision: 'auto_trusted', tier, time: Date.now(), rule_id: trust.rule?.id });
144
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'auto_trusted', scope: trust.rule?.scope, rule_id: trust.rule?.id });
145
+ write(renderTrustedApproval({ tool: toolName, args, scope: trust.rule?.scope, ruleId: trust.rule?.id }));
146
+ return { approved: true, tier, scope: trust.rule?.scope, rule_id: trust.rule?.id };
147
+ }
148
+ if (trust?.decision === 'reask' && trust.reason) {
149
+ context.reason = context.reason || context.why || `Re-asking: ${trust.reason}`;
150
+ }
151
+
125
152
  // Honor approve-all / type-allow shortcuts for non-explicit tiers only.
126
153
  if (!requiresExplicitApproval(tier)) {
127
154
  if (this.approveAll) {
128
155
  this.history.push({ tool: toolName, decision: 'auto-all', tier, time: Date.now() });
156
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'auto-all', scope: 'session' });
129
157
  return { approved: true, tier };
130
158
  }
131
159
  if (this.approvedToolTypes.has(toolName)) {
132
160
  this.history.push({ tool: toolName, decision: 'type-auto', tier, time: Date.now() });
161
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'type-auto', scope: 'session' });
133
162
  return { approved: true, tier };
134
163
  }
135
164
  }
@@ -139,10 +168,9 @@ export class ApprovalManager {
139
168
 
140
169
  async _prompt(toolName, args, context = {}) {
141
170
  const tier = context.tier || classifyTier(toolName, args);
142
- const explicit = requiresExplicitApproval(tier);
143
171
  const why = context.reason || context.why || defaultWhy(tier, toolName, args);
144
172
  const summary = toolDisplaySummary(toolName, args);
145
- const options = approvalOptions(tier);
173
+ const options = this._optionsFor(tier);
146
174
 
147
175
  let selected = 0; // arrow-driven cursor
148
176
  let printedHeight = 0;
@@ -151,12 +179,10 @@ export class ApprovalManager {
151
179
  // live. For non-TTYs / pipes we just print once and read a line.
152
180
  const isInteractive = process.stdin.isTTY;
153
181
  if (!isInteractive) {
154
- write(explicit
155
- ? renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options }) + '\n'
156
- : renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
182
+ write(renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options }) + '\n');
157
183
  }
158
184
 
159
- const drawExplicit = () => {
185
+ const drawPrompt = () => {
160
186
  // Move up over the previous render before re-printing.
161
187
  if (printedHeight > 0) {
162
188
  write(`\x1b[${printedHeight}F`); // cursor to start of N lines above
@@ -167,8 +193,7 @@ export class ApprovalManager {
167
193
  printedHeight = block.split('\n').length;
168
194
  };
169
195
 
170
- if (isInteractive && explicit) drawExplicit();
171
- if (isInteractive && !explicit) write(renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
196
+ if (isInteractive) drawPrompt();
172
197
 
173
198
  // ── Input loop ─────────────────────────────────────────────────
174
199
  const choose = async () => {
@@ -176,15 +201,15 @@ export class ApprovalManager {
176
201
  const k = await this._readKey();
177
202
 
178
203
  if (k === 'up' || k === 'left') {
179
- if (!explicit || !isInteractive) continue;
204
+ if (!isInteractive) continue;
180
205
  selected = (selected - 1 + options.length) % options.length;
181
- drawExplicit();
206
+ drawPrompt();
182
207
  continue;
183
208
  }
184
209
  if (k === 'down' || k === 'right' || k === 'tab') {
185
- if (!explicit || !isInteractive) continue;
210
+ if (!isInteractive) continue;
186
211
  selected = (selected + 1) % options.length;
187
- drawExplicit();
212
+ drawPrompt();
188
213
  continue;
189
214
  }
190
215
  if (k === 'return') {
@@ -198,7 +223,7 @@ export class ApprovalManager {
198
223
  const idx = options.findIndex(o => o.key === lower);
199
224
  if (idx >= 0) {
200
225
  selected = idx;
201
- if (isInteractive && explicit) drawExplicit();
226
+ if (isInteractive) drawPrompt();
202
227
  return options[idx].value;
203
228
  }
204
229
  }
@@ -212,25 +237,51 @@ export class ApprovalManager {
212
237
  case 'approve':
213
238
  write(` ${GREEN}✓${RST} ${DIM}${toolName}${RST} ${DIM}${summary.slice(0, 60)}${RST}\n\n`);
214
239
  this.history.push({ tool: toolName, decision: 'yes', tier, time: Date.now() });
240
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve', scope: 'once' });
215
241
  return { approved: true, tier };
216
242
 
243
+ case 'allow-session': {
244
+ if (!this.policy.hitl?.allowSessionTrust) return this._prompt(toolName, args, context);
245
+ const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'SESSION' });
246
+ write(` ${GREEN}✓${RST} ${DIM}trusted for this session: ${rule.pattern}${RST}\n\n`);
247
+ this.history.push({ tool: toolName, decision: 'session-trust', tier, time: Date.now(), rule_id: rule.id });
248
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve_trusted', scope: 'SESSION', rule_id: rule.id });
249
+ return { approved: true, tier, scope: 'SESSION', rule_id: rule.id };
250
+ }
251
+
252
+ case 'allow-project': {
253
+ if (!this.policy.hitl?.allowProjectTrust) return this._prompt(toolName, args, context);
254
+ const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'PROJECT' });
255
+ write(` ${GREEN}✓${RST} ${DIM}trusted for this project: ${rule.pattern}${RST}\n\n`);
256
+ this.history.push({ tool: toolName, decision: 'project-trust', tier, time: Date.now(), rule_id: rule.id });
257
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve_trusted', scope: 'PROJECT', rule_id: rule.id });
258
+ return { approved: true, tier, scope: 'PROJECT', rule_id: rule.id };
259
+ }
260
+
217
261
  case 'reject':
218
- write(` ${RED}✗${RST} ${DIM}denied${RST}\n\n`);
219
- this.history.push({ tool: toolName, decision: 'no', tier, time: Date.now() });
220
- return { approved: false, tier, reason: 'User denied' };
262
+ {
263
+ const reason = 'User stopped the command';
264
+ write(` ${RED}✗${RST} ${DIM}stopped${RST}\n\n`);
265
+ this.history.push({ tool: toolName, decision: 'no', tier, time: Date.now(), reason });
266
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'reject', scope: 'once', reason });
267
+ this._rememberRejection({ tool: toolName, args, tier, decision: 'reject', reason, note: '' });
268
+ return { approved: false, tier, reason };
269
+ }
221
270
 
222
271
  case 'allow-all':
223
- if (explicit) return this._prompt(toolName, args, context);
272
+ if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
224
273
  this.approveAll = true;
225
274
  write(` ${GREEN}✓✓${RST} ${DIM}allow-all activated${RST}\n\n`);
226
275
  this.history.push({ tool: toolName, decision: 'approve-all', tier, time: Date.now() });
276
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve-all', scope: 'session' });
227
277
  return { approved: true, tier };
228
278
 
229
279
  case 'allow-type':
230
- if (explicit) return this._prompt(toolName, args, context);
280
+ if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
231
281
  this.approvedToolTypes.add(toolName);
232
282
  write(` ${GREEN}✓${RST} ${DIM}always allow ${toolName}${RST}\n\n`);
233
283
  this.history.push({ tool: toolName, decision: 'type-approve', tier, time: Date.now() });
284
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'type-approve', scope: 'session' });
234
285
  return { approved: true, tier };
235
286
 
236
287
  case 'why':
@@ -240,9 +291,15 @@ export class ApprovalManager {
240
291
 
241
292
  case 'edit':
242
293
  case 'replan':
243
- write(` ${YELLOW}↩${RST} ${DIM}reject with hint — rework the plan${RST}\n\n`);
244
- this.history.push({ tool: toolName, decision: 'replan', tier, time: Date.now() });
245
- return { approved: false, tier, reason: 'User asked to re-plan' };
294
+ {
295
+ const note = await this._readLinePrompt(` ${DIM}How would you like to proceed? ${RST}`);
296
+ const reason = note ? `User asked to re-plan: ${note}` : 'User asked to re-plan';
297
+ write(` ${YELLOW}↩${RST} ${DIM}${note ? `re-plan — ${truncateNote(note)}` : 'reject with hint — rework the plan'}${RST}\n\n`);
298
+ this.history.push({ tool: toolName, decision: 'replan', tier, time: Date.now(), reason });
299
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'replan', scope: 'once', reason });
300
+ this._rememberRejection({ tool: toolName, args, tier, decision: 'replan', reason, note });
301
+ return { approved: false, tier, reason };
302
+ }
246
303
 
247
304
  default:
248
305
  return this._prompt(toolName, args, context);
@@ -287,6 +344,70 @@ export class ApprovalManager {
287
344
  });
288
345
  }
289
346
 
347
+ _readLinePrompt(label) {
348
+ return new Promise((resolve) => {
349
+ if (!process.stdin.isTTY) {
350
+ resolve('');
351
+ return;
352
+ }
353
+
354
+ if (this._execPause) this._execPause();
355
+ if (this._rl) this._rl.pause();
356
+
357
+ const wasRaw = process.stdin.isRaw;
358
+ if (typeof process.stdin.setRawMode === 'function') {
359
+ process.stdin.setRawMode(false);
360
+ }
361
+ process.stdin.resume();
362
+ write(label);
363
+
364
+ let buffer = '';
365
+ const cleanup = () => {
366
+ process.stdin.off('data', onData);
367
+ if (typeof process.stdin.setRawMode === 'function') {
368
+ process.stdin.setRawMode(wasRaw || false);
369
+ }
370
+ if (this._rl) this._rl.resume();
371
+ if (this._execResume) this._execResume();
372
+ };
373
+ const finish = () => {
374
+ cleanup();
375
+ resolve(buffer.trim());
376
+ };
377
+ const onData = (data) => {
378
+ const str = data.toString();
379
+ if (data[0] === 0x03) process.exit(0);
380
+ if (data[0] === 0x1b) {
381
+ buffer = '';
382
+ write('\n');
383
+ finish();
384
+ return;
385
+ }
386
+ if (str.includes('\n') || str.includes('\r')) {
387
+ buffer += str.replace(/[\r\n].*$/s, '');
388
+ finish();
389
+ return;
390
+ }
391
+ buffer += str;
392
+ };
393
+
394
+ process.stdin.on('data', onData);
395
+ });
396
+ }
397
+
398
+ _optionsFor(tier) {
399
+ const options = approvalOptions(tier);
400
+ if (requiresExplicitApproval(tier)) {
401
+ if (this.policy.hitl?.allowSessionTrust) {
402
+ options.splice(1, 0, { key: 's', label: 'session', value: 'allow-session', hint: 'trust this pattern until expiry' });
403
+ }
404
+ if (this.policy.hitl?.allowProjectTrust) {
405
+ options.splice(1, 0, { key: 'a', label: 'project', value: 'allow-project', hint: 'trust this pattern in this repo' });
406
+ }
407
+ }
408
+ return options;
409
+ }
410
+
290
411
  getSummary() {
291
412
  const approved = this.history.filter(h => h.decision !== 'no').length;
292
413
  const denied = this.history.filter(h => h.decision === 'no').length;
@@ -296,6 +417,25 @@ export class ApprovalManager {
296
417
  denied,
297
418
  autoApproveAll: this.approveAll,
298
419
  autoApprovedTypes: [...this.approvedToolTypes],
420
+ trust: this.trustStore?.summary?.() || { sessionRules: 0, projectRules: 0 },
299
421
  };
300
422
  }
423
+
424
+ _rememberRejection(entry) {
425
+ this.rejectionHints.push({ ...entry, time: Date.now() });
426
+ if (this.rejectionHints.length > 10) {
427
+ this.rejectionHints.splice(0, this.rejectionHints.length - 10);
428
+ }
429
+ }
430
+
431
+ consumeRejectionHints() {
432
+ const hints = [...this.rejectionHints];
433
+ this.rejectionHints = [];
434
+ return hints;
435
+ }
436
+ }
437
+
438
+ function truncateNote(note) {
439
+ const text = String(note || '').trim();
440
+ return text.length <= 120 ? text : text.slice(0, 119) + '…';
301
441
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  const BACKEND_URLS = {
11
- local: 'http://127.0.0.1:8000',
11
+ local: 'http://127.0.0.1:8150',
12
12
  development: 'https://codekepler-backend-dev.kindisland-9034322d.eastus.azurecontainerapps.io',
13
13
  production: 'https://codekepler-backend-prod.gentlerock-9816c6b8.centralus.azurecontainerapps.io',
14
14
  };
@@ -18,7 +18,7 @@ BACKEND_URLS.dev = BACKEND_URLS.development;
18
18
  BACKEND_URLS.prod = BACKEND_URLS.production;
19
19
 
20
20
  const WEB_URLS = {
21
- local: 'http://localhost:3000',
21
+ local: 'http://localhost:3100',
22
22
  development: 'https://codekepler.ai',
23
23
  production: 'https://codekepler.ai',
24
24
  };