@askance/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/cli/index.js +515 -0
- package/dist/common/api-client.js +186 -0
- package/dist/hook-handler/index.js +143 -0
- package/dist/hook-handler/stop-hook.js +45 -0
- package/dist/mcp-server/index.js +236 -0
- package/dist/templates/ci-safe.yml +22 -0
- package/dist/templates/conservative.yml +30 -0
- package/dist/templates/copilot-config.json +11 -0
- package/dist/templates/cursor-config.json +18 -0
- package/dist/templates/moderate.yml +34 -0
- package/dist/templates/permissive.yml +18 -0
- package/package.json +51 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readConfig = readConfig;
|
|
7
|
+
exports.clearConfigCache = clearConfigCache;
|
|
8
|
+
exports.apiGet = apiGet;
|
|
9
|
+
exports.apiPost = apiPost;
|
|
10
|
+
exports.intercept = intercept;
|
|
11
|
+
exports.getInstructions = getInstructions;
|
|
12
|
+
exports.waitForInstruction = waitForInstruction;
|
|
13
|
+
exports.readKeepAliveConfig = readKeepAliveConfig;
|
|
14
|
+
const node_https_1 = __importDefault(require("node:https"));
|
|
15
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
16
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
18
|
+
const yaml_1 = require("yaml");
|
|
19
|
+
let cachedConfig = null;
|
|
20
|
+
function readConfig() {
|
|
21
|
+
if (cachedConfig)
|
|
22
|
+
return cachedConfig;
|
|
23
|
+
const projectDir = process.env.ASKANCE_PROJECT_DIR ?? process.cwd();
|
|
24
|
+
// Read API URL from .askance.yml or env
|
|
25
|
+
let apiUrl = process.env.ASKANCE_API_URL ?? "";
|
|
26
|
+
let projectId = process.env.ASKANCE_PROJECT_ID ?? "";
|
|
27
|
+
const policyPath = node_path_1.default.join(projectDir, ".askance.yml");
|
|
28
|
+
if (node_fs_1.default.existsSync(policyPath)) {
|
|
29
|
+
try {
|
|
30
|
+
const yml = (0, yaml_1.parse)(node_fs_1.default.readFileSync(policyPath, "utf-8"));
|
|
31
|
+
if (yml?.api_url && !apiUrl)
|
|
32
|
+
apiUrl = yml.api_url;
|
|
33
|
+
if (yml?.project_id && !projectId)
|
|
34
|
+
projectId = yml.project_id;
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
}
|
|
38
|
+
// Read auth token from credentials file or env
|
|
39
|
+
let token = process.env.ASKANCE_TOKEN ?? "";
|
|
40
|
+
let refreshToken = "";
|
|
41
|
+
const credPath = node_path_1.default.join(projectDir, ".askance", "credentials");
|
|
42
|
+
if (node_fs_1.default.existsSync(credPath)) {
|
|
43
|
+
try {
|
|
44
|
+
const creds = JSON.parse(node_fs_1.default.readFileSync(credPath, "utf-8"));
|
|
45
|
+
if (!token && creds.token)
|
|
46
|
+
token = creds.token;
|
|
47
|
+
if (creds.refresh_token)
|
|
48
|
+
refreshToken = creds.refresh_token;
|
|
49
|
+
if (creds.api_url && !apiUrl)
|
|
50
|
+
apiUrl = creds.api_url;
|
|
51
|
+
if (creds.project_id && !projectId)
|
|
52
|
+
projectId = creds.project_id;
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
if (!apiUrl)
|
|
57
|
+
apiUrl = "https://api.askance.app";
|
|
58
|
+
cachedConfig = { apiUrl, token, refreshToken, projectId };
|
|
59
|
+
return cachedConfig;
|
|
60
|
+
}
|
|
61
|
+
function clearConfigCache() {
|
|
62
|
+
cachedConfig = null;
|
|
63
|
+
}
|
|
64
|
+
function rawRequest(method, urlPath, authToken, apiUrl, body, timeoutMs = 120_000) {
|
|
65
|
+
const url = new URL(urlPath, apiUrl);
|
|
66
|
+
const isHttps = url.protocol === "https:";
|
|
67
|
+
const transport = isHttps ? node_https_1.default : node_http_1.default;
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const bodyStr = body ? JSON.stringify(body) : undefined;
|
|
70
|
+
const req = transport.request({
|
|
71
|
+
hostname: url.hostname,
|
|
72
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
73
|
+
path: url.pathname + url.search,
|
|
74
|
+
method,
|
|
75
|
+
headers: {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
...(bodyStr ? { "Content-Length": Buffer.byteLength(bodyStr) } : {}),
|
|
78
|
+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
79
|
+
},
|
|
80
|
+
timeout: timeoutMs,
|
|
81
|
+
}, (res) => {
|
|
82
|
+
let data = "";
|
|
83
|
+
res.on("data", (chunk) => (data += chunk));
|
|
84
|
+
res.on("end", () => {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(data);
|
|
87
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
88
|
+
resolve({ ok: true, data: parsed, status: res.statusCode });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
resolve({
|
|
92
|
+
ok: false,
|
|
93
|
+
error: parsed.error || parsed.message || `HTTP ${res.statusCode}`,
|
|
94
|
+
status: res.statusCode,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
100
|
+
resolve({ ok: true, status: res.statusCode });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
resolve({ ok: false, error: data || `HTTP ${res.statusCode}`, status: res.statusCode });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
req.on("error", (err) => {
|
|
109
|
+
resolve({ ok: false, error: err.message });
|
|
110
|
+
});
|
|
111
|
+
req.on("timeout", () => {
|
|
112
|
+
req.destroy();
|
|
113
|
+
resolve({ ok: false, error: "Request timed out" });
|
|
114
|
+
});
|
|
115
|
+
if (bodyStr)
|
|
116
|
+
req.write(bodyStr);
|
|
117
|
+
req.end();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function tryRefreshToken() {
|
|
121
|
+
const config = readConfig();
|
|
122
|
+
if (!config.refreshToken)
|
|
123
|
+
return false;
|
|
124
|
+
const result = await rawRequest("POST", "/api/auth/refresh", "", config.apiUrl, { refreshToken: config.refreshToken });
|
|
125
|
+
if (!result.ok || !result.data)
|
|
126
|
+
return false;
|
|
127
|
+
// Update credentials file
|
|
128
|
+
const projectDir = process.env.ASKANCE_PROJECT_DIR ?? process.cwd();
|
|
129
|
+
const credPath = node_path_1.default.join(projectDir, ".askance", "credentials");
|
|
130
|
+
if (node_fs_1.default.existsSync(credPath)) {
|
|
131
|
+
try {
|
|
132
|
+
const creds = JSON.parse(node_fs_1.default.readFileSync(credPath, "utf-8"));
|
|
133
|
+
creds.token = result.data.token;
|
|
134
|
+
creds.refresh_token = result.data.refreshToken;
|
|
135
|
+
node_fs_1.default.writeFileSync(credPath, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
}
|
|
139
|
+
// Update cached config
|
|
140
|
+
clearConfigCache();
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
async function makeRequest(method, urlPath, body, timeoutMs = 120_000) {
|
|
144
|
+
const config = readConfig();
|
|
145
|
+
const result = await rawRequest(method, urlPath, config.token, config.apiUrl, body, timeoutMs);
|
|
146
|
+
// On 401, try refreshing the token and retry once
|
|
147
|
+
if (result.status === 401) {
|
|
148
|
+
const refreshed = await tryRefreshToken();
|
|
149
|
+
if (refreshed) {
|
|
150
|
+
const newConfig = readConfig();
|
|
151
|
+
return rawRequest(method, urlPath, newConfig.token, newConfig.apiUrl, body, timeoutMs);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
function apiGet(urlPath, timeoutMs) {
|
|
157
|
+
return makeRequest("GET", urlPath, undefined, timeoutMs);
|
|
158
|
+
}
|
|
159
|
+
function apiPost(urlPath, body, timeoutMs) {
|
|
160
|
+
return makeRequest("POST", urlPath, body, timeoutMs);
|
|
161
|
+
}
|
|
162
|
+
function intercept(payload) {
|
|
163
|
+
return apiPost("/api/intercept", payload);
|
|
164
|
+
}
|
|
165
|
+
function getInstructions(projectId) {
|
|
166
|
+
return apiGet(`/api/instructions/${projectId}`);
|
|
167
|
+
}
|
|
168
|
+
function waitForInstruction(projectId, timeoutMs) {
|
|
169
|
+
return apiGet(`/api/instructions/${projectId}/wait?timeout=${timeoutMs}`, timeoutMs + 5000);
|
|
170
|
+
}
|
|
171
|
+
function readKeepAliveConfig() {
|
|
172
|
+
const projectDir = process.env.ASKANCE_PROJECT_DIR ?? process.cwd();
|
|
173
|
+
const policyPath = node_path_1.default.join(projectDir, ".askance.yml");
|
|
174
|
+
try {
|
|
175
|
+
const yml = (0, yaml_1.parse)(node_fs_1.default.readFileSync(policyPath, "utf-8"));
|
|
176
|
+
return {
|
|
177
|
+
enabled: yml?.keep_alive?.enabled ?? true,
|
|
178
|
+
duration: yml?.keep_alive?.duration ?? 3600,
|
|
179
|
+
poll_interval: yml?.keep_alive?.poll_interval ?? 30,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { enabled: true, duration: 3600, poll_interval: 30 };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=api-client.js.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
7
|
+
const api_client_1 = require("../common/api-client");
|
|
8
|
+
const CONTEXT_LINES = 20;
|
|
9
|
+
function readStdin() {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let data = "";
|
|
12
|
+
process.stdin.setEncoding("utf-8");
|
|
13
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
14
|
+
process.stdin.on("end", () => resolve(data));
|
|
15
|
+
process.stdin.on("error", reject);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function extractContext(transcriptPath) {
|
|
19
|
+
try {
|
|
20
|
+
if (!transcriptPath || !node_fs_1.default.existsSync(transcriptPath))
|
|
21
|
+
return "";
|
|
22
|
+
const content = node_fs_1.default.readFileSync(transcriptPath, "utf-8");
|
|
23
|
+
const lines = content.trim().split("\n");
|
|
24
|
+
const recent = lines.slice(-CONTEXT_LINES);
|
|
25
|
+
const fragments = [];
|
|
26
|
+
for (const line of recent) {
|
|
27
|
+
try {
|
|
28
|
+
const entry = JSON.parse(line);
|
|
29
|
+
if (entry.type === "assistant" && entry.message?.content) {
|
|
30
|
+
const parts = Array.isArray(entry.message.content)
|
|
31
|
+
? entry.message.content
|
|
32
|
+
: [entry.message.content];
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
if (typeof part === "string" && part.trim()) {
|
|
35
|
+
fragments.push(part.trim());
|
|
36
|
+
}
|
|
37
|
+
else if (part?.type === "text" && part.text?.trim()) {
|
|
38
|
+
fragments.push(part.text.trim());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// skip unparseable lines
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const combined = fragments.slice(-5).join("\n---\n");
|
|
48
|
+
return combined.length > 1000 ? combined.slice(-1000) : combined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function main() {
|
|
55
|
+
let input;
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readStdin();
|
|
58
|
+
input = JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
// Extract recent Claude reasoning from transcript
|
|
64
|
+
input.context_summary = extractContext(input.transcript_path ?? "");
|
|
65
|
+
const config = (0, api_client_1.readConfig)();
|
|
66
|
+
// Map to cloud API format (PascalCase)
|
|
67
|
+
const payload = {
|
|
68
|
+
ToolName: input.tool_name ?? "",
|
|
69
|
+
ToolInput: input.tool_input ? JSON.stringify(input.tool_input) : undefined,
|
|
70
|
+
SessionId: input.session_id,
|
|
71
|
+
Cwd: input.cwd,
|
|
72
|
+
ContextSummary: input.context_summary,
|
|
73
|
+
ProjectId: config.projectId || undefined,
|
|
74
|
+
};
|
|
75
|
+
const result = await (0, api_client_1.intercept)(payload);
|
|
76
|
+
if (!result.ok || !result.data) {
|
|
77
|
+
// Cloud API unreachable — fallback to "ask"
|
|
78
|
+
const output = JSON.stringify({
|
|
79
|
+
hookSpecificOutput: {
|
|
80
|
+
hookEventName: "PreToolUse",
|
|
81
|
+
permissionDecision: "ask",
|
|
82
|
+
permissionDecisionReason: "Askance cloud API unavailable, falling back to manual approval",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
process.stdout.write(output);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
const response = result.data;
|
|
89
|
+
if (response.action === "allow") {
|
|
90
|
+
// Check for pending operator instructions on every allowed call
|
|
91
|
+
let reason = `Auto-approved: ${response.rule ?? "policy"}`;
|
|
92
|
+
if (config.projectId) {
|
|
93
|
+
const instrResult = await (0, api_client_1.getInstructions)(config.projectId);
|
|
94
|
+
if (instrResult.ok && instrResult.data && instrResult.data.length > 0) {
|
|
95
|
+
const texts = instrResult.data.map((i) => i.text).join("\n");
|
|
96
|
+
reason += `\n\n[Askance] OPERATOR INSTRUCTION — The operator has sent you a new task via the dashboard. ` +
|
|
97
|
+
`After completing your current action, please address this:\n${texts}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
reason += `\n\n[Askance] KEEP-ALIVE: When you have no more tasks to work on, ` +
|
|
101
|
+
`do NOT go idle. Instead, call the mcp__askance__check_instructions tool ` +
|
|
102
|
+
`to enter a keep-alive polling loop for new operator instructions.`;
|
|
103
|
+
const output = JSON.stringify({
|
|
104
|
+
hookSpecificOutput: {
|
|
105
|
+
hookEventName: "PreToolUse",
|
|
106
|
+
permissionDecision: "allow",
|
|
107
|
+
permissionDecisionReason: reason,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
process.stdout.write(output);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
if (response.action === "pending") {
|
|
114
|
+
const output = JSON.stringify({
|
|
115
|
+
hookSpecificOutput: {
|
|
116
|
+
hookEventName: "PreToolUse",
|
|
117
|
+
permissionDecision: "deny",
|
|
118
|
+
permissionDecisionReason: `PENDING APPROVAL (${response.approval_id}): ${response.rule}. ` +
|
|
119
|
+
`This action is queued for human approval on the Askance dashboard. ` +
|
|
120
|
+
`Continue working on other tasks. When you need the result, call the ` +
|
|
121
|
+
`mcp__askance__wait tool with approval_id "${response.approval_id}" ` +
|
|
122
|
+
`to check if it has been approved. Once approved, retry the original action.`,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
process.stdout.write(output);
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
if (response.action === "deny") {
|
|
129
|
+
const output = JSON.stringify({
|
|
130
|
+
hookSpecificOutput: {
|
|
131
|
+
hookEventName: "PreToolUse",
|
|
132
|
+
permissionDecision: "deny",
|
|
133
|
+
permissionDecisionReason: response.reason ?? `Denied: ${response.rule ?? "policy"}`,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
process.stdout.write(output);
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
// Unknown response — fallback to ask
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
main().catch(() => process.exit(0));
|
|
143
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const api_client_1 = require("../common/api-client");
|
|
4
|
+
function readStdin() {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
let data = "";
|
|
7
|
+
process.stdin.setEncoding("utf-8");
|
|
8
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
9
|
+
process.stdin.on("end", () => resolve(data));
|
|
10
|
+
process.stdin.on("error", reject);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
async function main() {
|
|
14
|
+
let input;
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readStdin();
|
|
17
|
+
input = JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
const config = (0, api_client_1.readConfig)();
|
|
23
|
+
if (!config.projectId) {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
// Check for queued instructions from the dashboard
|
|
27
|
+
try {
|
|
28
|
+
const result = await (0, api_client_1.getInstructions)(config.projectId);
|
|
29
|
+
if (result.ok && result.data && result.data.length > 0) {
|
|
30
|
+
const texts = result.data.map((i) => i.text).join("\n\n");
|
|
31
|
+
const output = JSON.stringify({
|
|
32
|
+
notification: `[Askance] New instruction from operator:\n\n${texts}`,
|
|
33
|
+
});
|
|
34
|
+
process.stdout.write(output);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// API unavailable — silently continue
|
|
40
|
+
}
|
|
41
|
+
// No instructions — just exit cleanly
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
main().catch(() => process.exit(0));
|
|
45
|
+
//# sourceMappingURL=stop-hook.js.map
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
4
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const api_client_1 = require("../common/api-client");
|
|
7
|
+
const server = new mcp_js_1.McpServer({
|
|
8
|
+
name: "askance",
|
|
9
|
+
version: "0.1.0",
|
|
10
|
+
});
|
|
11
|
+
// Tool: wait for an approval decision (long-polls the cloud API)
|
|
12
|
+
server.tool("wait", "Wait for a pending Askance approval to be decided. Use this after a tool call was denied with a PENDING APPROVAL message. This will block until the operator approves or denies the request on the dashboard, or until it times out. Returns the approval status.", {
|
|
13
|
+
approval_id: zod_1.z
|
|
14
|
+
.string()
|
|
15
|
+
.describe("The approval ID from the PENDING APPROVAL message"),
|
|
16
|
+
timeout_seconds: zod_1.z
|
|
17
|
+
.number()
|
|
18
|
+
.optional()
|
|
19
|
+
.default(120)
|
|
20
|
+
.describe("Max seconds to wait (default 120, max 300)"),
|
|
21
|
+
}, async ({ approval_id, timeout_seconds }) => {
|
|
22
|
+
const timeoutMs = Math.min((timeout_seconds ?? 120) * 1000, 300_000);
|
|
23
|
+
try {
|
|
24
|
+
const result = await (0, api_client_1.apiGet)(`/api/approvals/${approval_id}/wait?timeout=${timeoutMs}`, timeoutMs + 5000);
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text: `Error: ${result.error}` }],
|
|
28
|
+
isError: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const data = result.data;
|
|
32
|
+
const status = data.status;
|
|
33
|
+
if (status === "timeout") {
|
|
34
|
+
return {
|
|
35
|
+
content: [{
|
|
36
|
+
type: "text",
|
|
37
|
+
text: `Approval ${approval_id} is still pending. No decision yet. You can call wait again later or continue with other work.`,
|
|
38
|
+
}],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Cloud API returns status "decided" with nested approval object
|
|
42
|
+
const approval = (data.approval ?? data);
|
|
43
|
+
const approvalStatus = approval.status?.toLowerCase();
|
|
44
|
+
if (approvalStatus === "approved") {
|
|
45
|
+
return {
|
|
46
|
+
content: [{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: `APPROVED: ${approval_id}. The operator approved "${approval.toolName ?? approval.tool_name}". You can now retry the original action.`,
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (approvalStatus === "denied") {
|
|
53
|
+
return {
|
|
54
|
+
content: [{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: `DENIED: ${approval_id}. The operator denied "${approval.toolName ?? approval.tool_name}". Do not retry this action. Find an alternative approach or skip it.`,
|
|
57
|
+
}],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
content: [{
|
|
62
|
+
type: "text",
|
|
63
|
+
text: `Unknown status for ${approval_id}: ${JSON.stringify(data)}`,
|
|
64
|
+
}],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
content: [{
|
|
70
|
+
type: "text",
|
|
71
|
+
text: `Error waiting for approval: ${err instanceof Error ? err.message : String(err)}`,
|
|
72
|
+
}],
|
|
73
|
+
isError: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
// Tool: list all pending approvals
|
|
78
|
+
server.tool("pending", "List all pending Askance approval requests waiting for operator decision on the dashboard.", {}, async () => {
|
|
79
|
+
try {
|
|
80
|
+
const config = (0, api_client_1.readConfig)();
|
|
81
|
+
const path = config.projectId
|
|
82
|
+
? `/api/approvals?projectId=${config.projectId}`
|
|
83
|
+
: "/api/approvals";
|
|
84
|
+
const result = await (0, api_client_1.apiGet)(path);
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: "text", text: `Error: ${result.error}` }],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const items = result.data ?? [];
|
|
92
|
+
if (items.length === 0) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{
|
|
95
|
+
type: "text",
|
|
96
|
+
text: "No pending approvals. All previous requests have been decided.",
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const lines = items.map((r) => `- [${r.id}] ${r.toolName ?? r.tool_name} (${r.risk} risk) — ${r.rule} — ${new Date(r.createdAt).toLocaleTimeString()}`);
|
|
101
|
+
return {
|
|
102
|
+
content: [{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: `Pending approvals (${items.length}):\n${lines.join("\n")}`,
|
|
105
|
+
}],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
content: [{
|
|
111
|
+
type: "text",
|
|
112
|
+
text: `Error fetching pending approvals: ${err instanceof Error ? err.message : String(err)}`,
|
|
113
|
+
}],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
// Tool: check status of a specific approval
|
|
119
|
+
server.tool("status", "Check the current status of a specific Askance approval request by ID.", {
|
|
120
|
+
approval_id: zod_1.z.string().describe("The approval ID to check"),
|
|
121
|
+
}, async ({ approval_id }) => {
|
|
122
|
+
try {
|
|
123
|
+
const result = await (0, api_client_1.apiGet)(`/api/approvals/${approval_id}`);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
return {
|
|
126
|
+
content: [{
|
|
127
|
+
type: "text",
|
|
128
|
+
text: `Approval ${approval_id} not found or API unavailable.`,
|
|
129
|
+
}],
|
|
130
|
+
isError: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const data = result.data;
|
|
134
|
+
return {
|
|
135
|
+
content: [{
|
|
136
|
+
type: "text",
|
|
137
|
+
text: `Approval ${approval_id}: status=${data.status}, tool=${data.toolName ?? data.tool_name}, risk=${data.risk}, rule=${data.rule}`,
|
|
138
|
+
}],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: `Approval ${approval_id} not found or API unavailable.`,
|
|
146
|
+
}],
|
|
147
|
+
isError: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Tool: check for operator instructions (keep-alive polling loop)
|
|
152
|
+
server.tool("check_instructions", "Poll the Askance dashboard for new operator instructions. Call this in a keep-alive loop when you have no more tasks to work on. " +
|
|
153
|
+
"This will long-poll for up to poll_interval seconds, then return. If no instructions arrive, call this tool again " +
|
|
154
|
+
"to continue the keep-alive loop. Stop polling once the total keep-alive duration has elapsed (the response will tell you when to stop). " +
|
|
155
|
+
"IMPORTANT: You MUST call this tool when you finish all your tasks instead of going idle.", {
|
|
156
|
+
keep_alive_started_at: zod_1.z
|
|
157
|
+
.number()
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("Unix timestamp (ms) when the keep-alive loop started. Omit on first call — the tool will return it for subsequent calls."),
|
|
160
|
+
}, async ({ keep_alive_started_at }) => {
|
|
161
|
+
try {
|
|
162
|
+
const config = (0, api_client_1.readConfig)();
|
|
163
|
+
if (!config.projectId) {
|
|
164
|
+
return {
|
|
165
|
+
content: [{
|
|
166
|
+
type: "text",
|
|
167
|
+
text: "No project configured. Run 'npx askance login' to connect to your Askance project.",
|
|
168
|
+
}],
|
|
169
|
+
isError: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Read keep-alive config from local .askance.yml
|
|
173
|
+
const kaConfig = (0, api_client_1.readKeepAliveConfig)();
|
|
174
|
+
if (!kaConfig.enabled) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{
|
|
177
|
+
type: "text",
|
|
178
|
+
text: "Keep-alive polling is disabled in .askance.yml. You may stop.",
|
|
179
|
+
}],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const startedAt = keep_alive_started_at ?? now;
|
|
184
|
+
const elapsedMs = now - startedAt;
|
|
185
|
+
const durationMs = kaConfig.duration * 1000;
|
|
186
|
+
if (elapsedMs >= durationMs) {
|
|
187
|
+
return {
|
|
188
|
+
content: [{
|
|
189
|
+
type: "text",
|
|
190
|
+
text: `Keep-alive duration expired (${kaConfig.duration}s). No more instructions. You may stop polling.`,
|
|
191
|
+
}],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const remainingMs = durationMs - elapsedMs;
|
|
195
|
+
const pollMs = Math.min(kaConfig.poll_interval * 1000, remainingMs);
|
|
196
|
+
// Long-poll the cloud API for instructions
|
|
197
|
+
const result = await (0, api_client_1.waitForInstruction)(config.projectId, pollMs);
|
|
198
|
+
if (result.ok && result.data?.status === "instruction" && result.data.instructions?.length) {
|
|
199
|
+
const texts = result.data.instructions.map((i) => i.text).join("\n\n");
|
|
200
|
+
return {
|
|
201
|
+
content: [{
|
|
202
|
+
type: "text",
|
|
203
|
+
text: `NEW OPERATOR INSTRUCTION:\n\n${texts}\n\n` +
|
|
204
|
+
`Please execute this instruction now. When done, call check_instructions again ` +
|
|
205
|
+
`with keep_alive_started_at=${startedAt} to continue the keep-alive loop.`,
|
|
206
|
+
}],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const remainingSec = Math.round((durationMs - (Date.now() - startedAt)) / 1000);
|
|
210
|
+
return {
|
|
211
|
+
content: [{
|
|
212
|
+
type: "text",
|
|
213
|
+
text: `No instructions yet. Keep-alive: ${remainingSec}s remaining. ` +
|
|
214
|
+
`Call check_instructions again with keep_alive_started_at=${startedAt} to continue polling.`,
|
|
215
|
+
}],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return {
|
|
220
|
+
content: [{
|
|
221
|
+
type: "text",
|
|
222
|
+
text: `Error polling for instructions: ${err instanceof Error ? err.message : String(err)}`,
|
|
223
|
+
}],
|
|
224
|
+
isError: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
async function main() {
|
|
229
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
230
|
+
await server.connect(transport);
|
|
231
|
+
}
|
|
232
|
+
main().catch((err) => {
|
|
233
|
+
process.stderr.write(`Askance MCP server error: ${err}\n`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
|
236
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
api_url: https://api.askance.app
|
|
4
|
+
project_id: ""
|
|
5
|
+
|
|
6
|
+
keep_alive:
|
|
7
|
+
enabled: false
|
|
8
|
+
duration: 0
|
|
9
|
+
poll_interval: 0
|
|
10
|
+
rules:
|
|
11
|
+
- name: "Allow read-only tools"
|
|
12
|
+
match: { tool: "^(Read|Glob|Grep)$" }
|
|
13
|
+
action: allow
|
|
14
|
+
risk: low
|
|
15
|
+
- name: "Allow test commands"
|
|
16
|
+
match: { tool: "^Bash$", command: "^(npm test|pytest|dotnet test|go test|cargo test)" }
|
|
17
|
+
action: allow
|
|
18
|
+
risk: low
|
|
19
|
+
- name: "Deny everything else"
|
|
20
|
+
match: { tool: ".*" }
|
|
21
|
+
action: deny
|
|
22
|
+
risk: high
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
api_url: https://api.askance.app
|
|
4
|
+
project_id: ""
|
|
5
|
+
|
|
6
|
+
keep_alive:
|
|
7
|
+
enabled: true
|
|
8
|
+
duration: 3600
|
|
9
|
+
poll_interval: 30
|
|
10
|
+
rules:
|
|
11
|
+
- name: "Allow read-only tools"
|
|
12
|
+
match: { tool: "^(Read|Glob|Grep|WebSearch|WebFetch)$" }
|
|
13
|
+
action: allow
|
|
14
|
+
risk: low
|
|
15
|
+
- name: "Gate all writes"
|
|
16
|
+
match: { tool: "^(Write|Edit|NotebookEdit)$" }
|
|
17
|
+
action: gate
|
|
18
|
+
risk: medium
|
|
19
|
+
- name: "Gate bash commands"
|
|
20
|
+
match: { tool: "^Bash$" }
|
|
21
|
+
action: gate
|
|
22
|
+
risk: high
|
|
23
|
+
- name: "Deny destructive commands"
|
|
24
|
+
match: { tool: "^Bash$", command: "(rm -rf|drop table|format |shutdown|reboot)" }
|
|
25
|
+
action: deny
|
|
26
|
+
risk: high
|
|
27
|
+
- name: "Default - gate everything"
|
|
28
|
+
match: { tool: ".*" }
|
|
29
|
+
action: gate
|
|
30
|
+
risk: medium
|