@axplusb/kepler 2.0.7 → 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 +2 -2
- package/src/config/hook-runner.mjs +100 -0
- package/src/config/memory-loader.mjs +32 -0
- package/src/config/settings-loader.mjs +45 -0
- package/src/core/approval-log.mjs +104 -0
- package/src/core/approval.mjs +164 -24
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/headless.mjs +14 -1
- package/src/core/jsonl-writer.mjs +50 -0
- package/src/core/local-store.mjs +486 -5
- package/src/core/policy-resolver.mjs +156 -0
- package/src/core/project-context-loader.mjs +139 -0
- package/src/core/rate-limit-display.mjs +97 -0
- package/src/core/resume-mode.mjs +154 -0
- package/src/core/risk-tier.mjs +88 -2
- package/src/core/safety.mjs +3 -0
- package/src/core/session-manager.mjs +53 -10
- package/src/core/stream-client.mjs +69 -10
- package/src/core/system-prompt.mjs +6 -1
- package/src/core/tasks.mjs +130 -0
- package/src/core/tool-executor.mjs +72 -6
- package/src/core/trust.mjs +158 -0
- package/src/onboarding/preflight.mjs +27 -11
- package/src/permissions/command-classifier.mjs +78 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +7 -0
- package/src/terminal/repl.mjs +1735 -140
- package/src/tools/bash.mjs +57 -9
- package/src/tools/project-overview.mjs +83 -10
- package/src/ui/approval.mjs +154 -35
- package/src/ui/formatter.mjs +39 -5
- package/src/ui/mission-report.mjs +55 -22
- package/src/ui/slash-commands.mjs +57 -5
- package/src/ui/tool-card.mjs +51 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axplusb/kepler",
|
|
3
|
-
"version": "2.0
|
|
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
|
+
}
|
package/src/core/approval.mjs
CHANGED
|
@@ -20,9 +20,12 @@ import {
|
|
|
20
20
|
} from './risk-tier.mjs';
|
|
21
21
|
import {
|
|
22
22
|
renderApprovalPrompt,
|
|
23
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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 (!
|
|
204
|
+
if (!isInteractive) continue;
|
|
180
205
|
selected = (selected - 1 + options.length) % options.length;
|
|
181
|
-
|
|
206
|
+
drawPrompt();
|
|
182
207
|
continue;
|
|
183
208
|
}
|
|
184
209
|
if (k === 'down' || k === 'right' || k === 'tab') {
|
|
185
|
-
if (!
|
|
210
|
+
if (!isInteractive) continue;
|
|
186
211
|
selected = (selected + 1) % options.length;
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
244
|
-
this.
|
|
245
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { contextToPromptBlock } from './project-context-loader.mjs';
|
|
2
|
+
|
|
3
|
+
export function buildContextEnvelope({
|
|
4
|
+
cwd = process.cwd(),
|
|
5
|
+
command = null,
|
|
6
|
+
args = [],
|
|
7
|
+
source = 'repl',
|
|
8
|
+
dryRun = false,
|
|
9
|
+
aliasesResolved = [],
|
|
10
|
+
effectivePolicy,
|
|
11
|
+
projectContext,
|
|
12
|
+
activeHints = [],
|
|
13
|
+
projectResources = [],
|
|
14
|
+
agentContext = {},
|
|
15
|
+
} = {}) {
|
|
16
|
+
const policy = effectivePolicy?.policy || {};
|
|
17
|
+
const commandTimeouts = policy.commands?.timeouts || {};
|
|
18
|
+
const enabled = policy.commands?.enabled || [];
|
|
19
|
+
const activeCommand = command || null;
|
|
20
|
+
const specificTimeout = activeCommand ? commandTimeouts[`${activeCommand}Seconds`] : null;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
cwd,
|
|
24
|
+
project_resources: projectResources,
|
|
25
|
+
agent_context: agentContext,
|
|
26
|
+
project_context: {
|
|
27
|
+
loaded_files: projectContext?.loaded || [],
|
|
28
|
+
changed_files: projectContext?.changed?.map(f => ({ label: f.label, path: f.path, hash: f.hash })) || [],
|
|
29
|
+
prompt_block: contextToPromptBlock(projectContext),
|
|
30
|
+
},
|
|
31
|
+
command_context: {
|
|
32
|
+
active_command: activeCommand,
|
|
33
|
+
source,
|
|
34
|
+
args,
|
|
35
|
+
dry_run: dryRun,
|
|
36
|
+
aliases_resolved: aliasesResolved,
|
|
37
|
+
runtime_limits: {
|
|
38
|
+
command_timeout_seconds: specificTimeout || commandTimeouts.defaultSeconds || 300,
|
|
39
|
+
tool_timeout_seconds: 120,
|
|
40
|
+
approval_timeout_seconds: null,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
effective_options: {
|
|
44
|
+
commands_enabled: enabled,
|
|
45
|
+
plan_owner: policy.planning?.owner || 'auto',
|
|
46
|
+
hitl_default_scope: policy.hitl?.defaultScope || 'once',
|
|
47
|
+
reask_after_minutes: policy.hitl?.reaskAfterMinutes ?? 30,
|
|
48
|
+
hook_timeout_seconds: policy.hooks?.timeoutSeconds ?? 5,
|
|
49
|
+
},
|
|
50
|
+
context_hints: activeHints,
|
|
51
|
+
available_skills: projectContext?.available_skills || [],
|
|
52
|
+
task_state: projectContext?.task_state || [],
|
|
53
|
+
};
|
|
54
|
+
}
|