@askance/cli 0.1.0 → 0.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/LICENSE +21 -21
- package/README.md +79 -79
- package/dist/cli/index.js +241 -105
- package/dist/common/api-client.js +13 -14
- package/package.json +3 -5
- package/dist/templates/ci-safe.yml +0 -22
- package/dist/templates/conservative.yml +0 -30
- package/dist/templates/copilot-config.json +0 -11
- package/dist/templates/cursor-config.json +0 -18
- package/dist/templates/moderate.yml +0 -34
- package/dist/templates/permissive.yml +0 -18
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Advancer Limited
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Advancer Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
# @askance/cli
|
|
2
|
-
|
|
3
|
-
Tool call interception & approval management for AI coding agents.
|
|
4
|
-
|
|
5
|
-
Askance hooks into your coding agent (Claude Code, Cursor, GitHub Copilot) and routes tool calls through policy rules and a cloud dashboard for approval — so agents can work while you review from any device.
|
|
6
|
-
|
|
7
|
-
## Quick Start
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install --save-dev @askance/cli
|
|
11
|
-
npx askance init
|
|
12
|
-
npx askance login
|
|
13
|
-
# Restart your agent session
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## What it does
|
|
17
|
-
|
|
18
|
-
- **Hook handler** — intercepts tool calls before execution and evaluates them against your `.askance.yml` policy rules
|
|
19
|
-
- **MCP server** — provides tools for the agent to wait for approvals and check for instructions
|
|
20
|
-
- **CLI** — sets up config files and authenticates with the Askance cloud
|
|
21
|
-
|
|
22
|
-
## Commands
|
|
23
|
-
|
|
24
|
-
| Command | Description |
|
|
25
|
-
|---------|-------------|
|
|
26
|
-
| `npx askance init` | Set up hooks, MCP server, policy rules, and CLAUDE.md |
|
|
27
|
-
| `npx askance login` | Authenticate via browser (GitHub, Google, or Microsoft) |
|
|
28
|
-
| `npx askance template <name>` | Apply a policy template (`conservative`, `moderate`, `permissive`, `ci-safe`) |
|
|
29
|
-
| `npx askance help` | Show usage information |
|
|
30
|
-
|
|
31
|
-
### Init options
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
npx askance init # Claude Code (default)
|
|
35
|
-
npx askance init --cursor # Cursor IDE
|
|
36
|
-
npx askance init --copilot # GitHub Copilot
|
|
37
|
-
npx askance init --all # All agents
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Policy rules
|
|
41
|
-
|
|
42
|
-
Policy rules in `.askance.yml` control what happens when the agent uses a tool:
|
|
43
|
-
|
|
44
|
-
- **allow** — tool call proceeds immediately
|
|
45
|
-
- **gate** — queued for approval in the dashboard
|
|
46
|
-
- **deny** — blocked automatically
|
|
47
|
-
|
|
48
|
-
```yaml
|
|
49
|
-
rules:
|
|
50
|
-
- name: "Allow read-only tools"
|
|
51
|
-
match:
|
|
52
|
-
tool: "^(Read|Glob|Grep|WebSearch)$"
|
|
53
|
-
action: allow
|
|
54
|
-
|
|
55
|
-
- name: "Gate file writes"
|
|
56
|
-
match:
|
|
57
|
-
tool: "^(Edit|Write)$"
|
|
58
|
-
action: gate
|
|
59
|
-
risk: medium
|
|
60
|
-
|
|
61
|
-
- name: "Deny destructive commands"
|
|
62
|
-
match:
|
|
63
|
-
tool: "^Bash$"
|
|
64
|
-
command: "(rm -rf|chmod 777)"
|
|
65
|
-
action: deny
|
|
66
|
-
risk: high
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
## Dashboard
|
|
70
|
-
|
|
71
|
-
Manage approvals at [app.askance.app](https://app.askance.app) — approve, deny, or send instructions to your agent from any device.
|
|
72
|
-
|
|
73
|
-
## Documentation
|
|
74
|
-
|
|
75
|
-
Full docs at [askance.app/docs](https://askance.app/docs)
|
|
76
|
-
|
|
77
|
-
## License
|
|
78
|
-
|
|
79
|
-
MIT
|
|
1
|
+
# @askance/cli
|
|
2
|
+
|
|
3
|
+
Tool call interception & approval management for AI coding agents.
|
|
4
|
+
|
|
5
|
+
Askance hooks into your coding agent (Claude Code, Cursor, GitHub Copilot) and routes tool calls through policy rules and a cloud dashboard for approval — so agents can work while you review from any device.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev @askance/cli
|
|
11
|
+
npx askance init
|
|
12
|
+
npx askance login
|
|
13
|
+
# Restart your agent session
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
- **Hook handler** — intercepts tool calls before execution and evaluates them against your `.askance.yml` policy rules
|
|
19
|
+
- **MCP server** — provides tools for the agent to wait for approvals and check for instructions
|
|
20
|
+
- **CLI** — sets up config files and authenticates with the Askance cloud
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
| Command | Description |
|
|
25
|
+
|---------|-------------|
|
|
26
|
+
| `npx askance init` | Set up hooks, MCP server, policy rules, and CLAUDE.md |
|
|
27
|
+
| `npx askance login` | Authenticate via browser (GitHub, Google, or Microsoft) |
|
|
28
|
+
| `npx askance template <name>` | Apply a policy template (`conservative`, `moderate`, `permissive`, `ci-safe`) |
|
|
29
|
+
| `npx askance help` | Show usage information |
|
|
30
|
+
|
|
31
|
+
### Init options
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx askance init # Claude Code (default)
|
|
35
|
+
npx askance init --cursor # Cursor IDE
|
|
36
|
+
npx askance init --copilot # GitHub Copilot
|
|
37
|
+
npx askance init --all # All agents
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Policy rules
|
|
41
|
+
|
|
42
|
+
Policy rules in `.askance.yml` control what happens when the agent uses a tool:
|
|
43
|
+
|
|
44
|
+
- **allow** — tool call proceeds immediately
|
|
45
|
+
- **gate** — queued for approval in the dashboard
|
|
46
|
+
- **deny** — blocked automatically
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
rules:
|
|
50
|
+
- name: "Allow read-only tools"
|
|
51
|
+
match:
|
|
52
|
+
tool: "^(Read|Glob|Grep|WebSearch)$"
|
|
53
|
+
action: allow
|
|
54
|
+
|
|
55
|
+
- name: "Gate file writes"
|
|
56
|
+
match:
|
|
57
|
+
tool: "^(Edit|Write)$"
|
|
58
|
+
action: gate
|
|
59
|
+
risk: medium
|
|
60
|
+
|
|
61
|
+
- name: "Deny destructive commands"
|
|
62
|
+
match:
|
|
63
|
+
tool: "^Bash$"
|
|
64
|
+
command: "(rm -rf|chmod 777)"
|
|
65
|
+
action: deny
|
|
66
|
+
risk: high
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Dashboard
|
|
70
|
+
|
|
71
|
+
Manage approvals at [app.askance.app](https://app.askance.app) — approve, deny, or send instructions to your agent from any device.
|
|
72
|
+
|
|
73
|
+
## Documentation
|
|
74
|
+
|
|
75
|
+
Full docs at [askance.app/docs](https://askance.app/docs)
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/dist/cli/index.js
CHANGED
|
@@ -39,6 +39,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
39
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
40
|
const fs_1 = __importDefault(require("fs"));
|
|
41
41
|
const path_1 = __importDefault(require("path"));
|
|
42
|
+
const child_process_1 = require("child_process");
|
|
43
|
+
const readline_1 = __importDefault(require("readline"));
|
|
42
44
|
const api_client_1 = require("../common/api-client");
|
|
43
45
|
const COMMANDS = ["init", "login", "template", "help"];
|
|
44
46
|
const command = (process.argv[2] ?? "help");
|
|
@@ -53,76 +55,42 @@ const pkgRoot = path_1.default.resolve(__dirname, "..", "..");
|
|
|
53
55
|
// ---------------------------------------------------------------------------
|
|
54
56
|
// Shared helpers
|
|
55
57
|
// ---------------------------------------------------------------------------
|
|
56
|
-
function
|
|
57
|
-
const
|
|
58
|
-
if (!fs_1.default.existsSync(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- name: "Allow safe bash commands"
|
|
76
|
-
match:
|
|
77
|
-
tool: "^Bash$"
|
|
78
|
-
command: "^(npm test|npm run lint|npm run build|git status|git diff|git log|ls)"
|
|
79
|
-
action: allow
|
|
80
|
-
|
|
81
|
-
- name: "Gate file writes"
|
|
82
|
-
match:
|
|
83
|
-
tool: "^(Edit|Write|NotebookEdit)$"
|
|
84
|
-
action: gate
|
|
85
|
-
risk: medium
|
|
86
|
-
|
|
87
|
-
- name: "Gate package installs"
|
|
88
|
-
match:
|
|
89
|
-
tool: "^Bash$"
|
|
90
|
-
command: "(npm install|pip install|dotnet add)"
|
|
91
|
-
action: gate
|
|
92
|
-
risk: medium
|
|
93
|
-
|
|
94
|
-
- name: "Deny destructive commands"
|
|
95
|
-
match:
|
|
96
|
-
tool: "^Bash$"
|
|
97
|
-
command: "(rm -rf|chmod 777|curl.*\\\\|.*bash)"
|
|
98
|
-
action: deny
|
|
99
|
-
risk: high
|
|
100
|
-
|
|
101
|
-
- name: "Default - gate everything else"
|
|
102
|
-
match:
|
|
103
|
-
tool: ".*"
|
|
104
|
-
action: gate
|
|
105
|
-
risk: low
|
|
106
|
-
`;
|
|
107
|
-
fs_1.default.writeFileSync(policyPath, defaultPolicy);
|
|
108
|
-
console.log("[askance] Created .askance.yml");
|
|
58
|
+
function ensureConfig() {
|
|
59
|
+
const askanceDir = path_1.default.join(projectDir, ".askance");
|
|
60
|
+
if (!fs_1.default.existsSync(askanceDir)) {
|
|
61
|
+
fs_1.default.mkdirSync(askanceDir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
const configPath = path_1.default.join(askanceDir, "config.json");
|
|
64
|
+
if (!fs_1.default.existsSync(configPath)) {
|
|
65
|
+
const defaultConfig = {
|
|
66
|
+
apiUrl: "https://api.askance.app",
|
|
67
|
+
projectId: "",
|
|
68
|
+
keepAlive: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
duration: 3600,
|
|
71
|
+
pollInterval: 30,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
fs_1.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + "\n");
|
|
75
|
+
console.log("[askance] Created .askance/config.json");
|
|
109
76
|
}
|
|
110
77
|
else {
|
|
111
|
-
console.log("[askance] .askance.
|
|
78
|
+
console.log("[askance] .askance/config.json already exists, skipping");
|
|
112
79
|
}
|
|
113
80
|
}
|
|
114
81
|
function ensureGitignore() {
|
|
115
82
|
const gitignorePath = path_1.default.join(projectDir, ".gitignore");
|
|
83
|
+
const ignoreEntry = ".askance/credentials";
|
|
116
84
|
if (fs_1.default.existsSync(gitignorePath)) {
|
|
117
85
|
const content = fs_1.default.readFileSync(gitignorePath, "utf-8");
|
|
118
|
-
if (!content.includes(
|
|
119
|
-
fs_1.default.appendFileSync(gitignorePath, "\n# Askance
|
|
120
|
-
console.log("[askance] Added .askance/ to .gitignore");
|
|
86
|
+
if (!content.includes(ignoreEntry)) {
|
|
87
|
+
fs_1.default.appendFileSync(gitignorePath, "\n# Askance credentials (do not commit)\n.askance/credentials\n");
|
|
88
|
+
console.log("[askance] Added .askance/credentials to .gitignore");
|
|
121
89
|
}
|
|
122
90
|
}
|
|
123
91
|
else {
|
|
124
|
-
fs_1.default.writeFileSync(gitignorePath, "# Askance
|
|
125
|
-
console.log("[askance] Created .gitignore with .askance/");
|
|
92
|
+
fs_1.default.writeFileSync(gitignorePath, "# Askance credentials (do not commit)\n.askance/credentials\n");
|
|
93
|
+
console.log("[askance] Created .gitignore with .askance/credentials");
|
|
126
94
|
}
|
|
127
95
|
}
|
|
128
96
|
// ---------------------------------------------------------------------------
|
|
@@ -247,7 +215,7 @@ const CLAUDE_MD_SECTION = `
|
|
|
247
215
|
This project uses [Askance](https://askance.app) for AI tool call interception and approval management.
|
|
248
216
|
|
|
249
217
|
**How it works:**
|
|
250
|
-
- All tool calls are intercepted by Askance hooks and evaluated against policy rules
|
|
218
|
+
- All tool calls are intercepted by Askance hooks and evaluated against policy rules managed via the dashboard
|
|
251
219
|
- Safe operations (reads, searches) are auto-approved; risky operations require human approval
|
|
252
220
|
- When a tool call is gated for approval, use \`mcp__askance__wait\` to wait for the decision
|
|
253
221
|
- Use \`mcp__askance__check_instructions\` periodically to check for operator instructions
|
|
@@ -275,8 +243,8 @@ function ensureClaudeMd() {
|
|
|
275
243
|
}
|
|
276
244
|
function init() {
|
|
277
245
|
console.log("[askance] Initializing Askance in", projectDir);
|
|
278
|
-
// 1. Always
|
|
279
|
-
|
|
246
|
+
// 1. Always create .askance/config.json + update .gitignore
|
|
247
|
+
ensureConfig();
|
|
280
248
|
ensureGitignore();
|
|
281
249
|
// 2. Determine which IDE configs to set up
|
|
282
250
|
const setupClaude = flagAll || (!flagCursor && !flagCopilot);
|
|
@@ -305,7 +273,139 @@ function init() {
|
|
|
305
273
|
console.log(" 2. Copilot agent hooks are configured in .github/copilot-config.json");
|
|
306
274
|
}
|
|
307
275
|
console.log(" 3. Open the dashboard: https://app.askance.app");
|
|
308
|
-
console.log(" 4.
|
|
276
|
+
console.log(" 4. Manage policy rules via the dashboard");
|
|
277
|
+
}
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Project setup helpers
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
function detectGitInfo() {
|
|
282
|
+
let repoUrl = "";
|
|
283
|
+
let branch = "main";
|
|
284
|
+
let repoName = path_1.default.basename(projectDir);
|
|
285
|
+
try {
|
|
286
|
+
repoUrl = (0, child_process_1.execSync)("git remote get-url origin", {
|
|
287
|
+
cwd: projectDir,
|
|
288
|
+
encoding: "utf-8",
|
|
289
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
290
|
+
}).trim();
|
|
291
|
+
}
|
|
292
|
+
catch { }
|
|
293
|
+
try {
|
|
294
|
+
branch = (0, child_process_1.execSync)("git branch --show-current", {
|
|
295
|
+
cwd: projectDir,
|
|
296
|
+
encoding: "utf-8",
|
|
297
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
298
|
+
}).trim() || "main";
|
|
299
|
+
}
|
|
300
|
+
catch { }
|
|
301
|
+
// Extract repo name from URL (e.g. git@github.com:user/my-repo.git → my-repo)
|
|
302
|
+
if (repoUrl) {
|
|
303
|
+
const match = repoUrl.match(/\/([^/]+?)(?:\.git)?$/) ?? repoUrl.match(/:([^/]+?)(?:\.git)?$/);
|
|
304
|
+
if (match)
|
|
305
|
+
repoName = match[1];
|
|
306
|
+
}
|
|
307
|
+
return { repoUrl, branch, repoName };
|
|
308
|
+
}
|
|
309
|
+
function promptSelection(message, max) {
|
|
310
|
+
const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
311
|
+
return new Promise((resolve) => {
|
|
312
|
+
rl.question(message, (answer) => {
|
|
313
|
+
rl.close();
|
|
314
|
+
const num = parseInt(answer.trim(), 10);
|
|
315
|
+
if (isNaN(num) || num < 1 || num > max) {
|
|
316
|
+
resolve(-1);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
resolve(num);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async function setupProject() {
|
|
325
|
+
console.log("\n[askance] Setting up project...");
|
|
326
|
+
// Fetch existing projects
|
|
327
|
+
const projectsResult = await (0, api_client_1.apiGet)("/api/projects");
|
|
328
|
+
if (!projectsResult.ok || !projectsResult.data) {
|
|
329
|
+
console.log(" Could not fetch projects. You can link a project later via the dashboard.");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const projects = projectsResult.data;
|
|
333
|
+
let selectedId = "";
|
|
334
|
+
let selectedName = "";
|
|
335
|
+
if (projects.length === 1) {
|
|
336
|
+
// Auto-select the only project
|
|
337
|
+
selectedId = projects[0].id;
|
|
338
|
+
selectedName = projects[0].name;
|
|
339
|
+
console.log(` Found 1 existing project: ${selectedName}`);
|
|
340
|
+
console.log(` Using project: ${selectedName} (${selectedId})`);
|
|
341
|
+
}
|
|
342
|
+
else if (projects.length > 1) {
|
|
343
|
+
// Prompt the user to select
|
|
344
|
+
console.log(` Found ${projects.length} projects:`);
|
|
345
|
+
for (let i = 0; i < projects.length; i++) {
|
|
346
|
+
console.log(` ${i + 1}. ${projects[i].name}`);
|
|
347
|
+
}
|
|
348
|
+
const choice = await promptSelection(` Select a project (1-${projects.length}): `, projects.length);
|
|
349
|
+
if (choice < 1) {
|
|
350
|
+
console.log(" Invalid selection. You can link a project later via the dashboard.");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
selectedId = projects[choice - 1].id;
|
|
354
|
+
selectedName = projects[choice - 1].name;
|
|
355
|
+
console.log(` Using project: ${selectedName} (${selectedId})`);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// No projects — auto-create one
|
|
359
|
+
const git = detectGitInfo();
|
|
360
|
+
console.log(` No projects found. Creating "${git.repoName}" from ${git.repoUrl ? "git remote" : "directory name"}...`);
|
|
361
|
+
const createResult = await (0, api_client_1.apiPost)("/api/projects", {
|
|
362
|
+
Name: git.repoName,
|
|
363
|
+
RepoUrl: git.repoUrl,
|
|
364
|
+
Branch: git.branch,
|
|
365
|
+
});
|
|
366
|
+
if (!createResult.ok) {
|
|
367
|
+
if (createResult.status === 402) {
|
|
368
|
+
console.log("\n Your plan allows 1 project and you've reached the limit.");
|
|
369
|
+
console.log(" Upgrade at https://app.askance.app/settings");
|
|
370
|
+
console.log(" You're still logged in — link a project manually or upgrade first.");
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
console.log(` Failed to create project: ${createResult.error}`);
|
|
374
|
+
console.log(" You can create a project later via the dashboard.");
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!createResult.data) {
|
|
379
|
+
console.log(" Failed to create project. You can create one later via the dashboard.");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
selectedId = createResult.data.id;
|
|
383
|
+
selectedName = createResult.data.name;
|
|
384
|
+
console.log(` Created project "${selectedName}"`);
|
|
385
|
+
console.log(` Project ID: ${selectedId}`);
|
|
386
|
+
}
|
|
387
|
+
// Save project ID to .askance/config.json
|
|
388
|
+
const configPath = path_1.default.join(projectDir, ".askance", "config.json");
|
|
389
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
390
|
+
try {
|
|
391
|
+
const cfg = JSON.parse(fs_1.default.readFileSync(configPath, "utf-8"));
|
|
392
|
+
cfg.projectId = selectedId;
|
|
393
|
+
fs_1.default.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
394
|
+
}
|
|
395
|
+
catch { }
|
|
396
|
+
}
|
|
397
|
+
// Also save to credentials for backward compatibility
|
|
398
|
+
const credPath = path_1.default.join(projectDir, ".askance", "credentials");
|
|
399
|
+
if (fs_1.default.existsSync(credPath)) {
|
|
400
|
+
try {
|
|
401
|
+
const creds = JSON.parse(fs_1.default.readFileSync(credPath, "utf-8"));
|
|
402
|
+
creds.project_id = selectedId;
|
|
403
|
+
fs_1.default.writeFileSync(credPath, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
404
|
+
}
|
|
405
|
+
catch { }
|
|
406
|
+
}
|
|
407
|
+
(0, api_client_1.clearConfigCache)();
|
|
408
|
+
console.log(" Project linked successfully.");
|
|
309
409
|
}
|
|
310
410
|
// ---------------------------------------------------------------------------
|
|
311
411
|
// Login — opens browser for auth, saves JWT to .askance/credentials
|
|
@@ -382,13 +482,16 @@ async function login() {
|
|
|
382
482
|
project_id: pollResult.data.projectId ?? "",
|
|
383
483
|
};
|
|
384
484
|
fs_1.default.writeFileSync(path_1.default.join(askanceDir, "credentials"), JSON.stringify(credentials, null, 2) + "\n", { mode: 0o600 });
|
|
385
|
-
// Update .askance.
|
|
485
|
+
// Update .askance/config.json project_id if we got one
|
|
386
486
|
if (pollResult.data.projectId) {
|
|
387
|
-
const
|
|
388
|
-
if (fs_1.default.existsSync(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
487
|
+
const configPath = path_1.default.join(projectDir, ".askance", "config.json");
|
|
488
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
489
|
+
try {
|
|
490
|
+
const cfg = JSON.parse(fs_1.default.readFileSync(configPath, "utf-8"));
|
|
491
|
+
cfg.projectId = pollResult.data.projectId;
|
|
492
|
+
fs_1.default.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
493
|
+
}
|
|
494
|
+
catch { }
|
|
392
495
|
}
|
|
393
496
|
}
|
|
394
497
|
(0, api_client_1.clearConfigCache)();
|
|
@@ -397,6 +500,10 @@ async function login() {
|
|
|
397
500
|
console.log(` Logged in as: ${pollResult.data.email}`);
|
|
398
501
|
}
|
|
399
502
|
console.log(" Credentials saved to .askance/credentials");
|
|
503
|
+
// If the server didn't provide a project_id, run interactive setup
|
|
504
|
+
if (!pollResult.data.projectId) {
|
|
505
|
+
await setupProject();
|
|
506
|
+
}
|
|
400
507
|
return;
|
|
401
508
|
}
|
|
402
509
|
if (pollResult.data.status === "expired") {
|
|
@@ -408,8 +515,35 @@ async function login() {
|
|
|
408
515
|
console.error("\n[askance] Login timed out. Please try again.");
|
|
409
516
|
process.exit(1);
|
|
410
517
|
}
|
|
411
|
-
|
|
412
|
-
|
|
518
|
+
const TEMPLATE_RULES = {
|
|
519
|
+
conservative: [
|
|
520
|
+
{ Name: "Allow read-only tools", ToolPattern: "^(Read|Glob|Grep|WebSearch|WebFetch)$", Action: "Allow", Risk: "Low" },
|
|
521
|
+
{ Name: "Gate file writes", ToolPattern: "^(Edit|Write|NotebookEdit)$", Action: "Gate", Risk: "Medium" },
|
|
522
|
+
{ Name: "Gate all bash commands", ToolPattern: "^Bash$", Action: "Gate", Risk: "Medium" },
|
|
523
|
+
{ Name: "Deny destructive commands", ToolPattern: "^Bash$", CommandPattern: "(rm -rf|chmod 777|curl.*\\\\|.*bash)", Action: "Deny", Risk: "High" },
|
|
524
|
+
{ Name: "Default — gate everything else", ToolPattern: ".*", Action: "Gate", Risk: "Low" },
|
|
525
|
+
],
|
|
526
|
+
moderate: [
|
|
527
|
+
{ Name: "Allow read-only tools", ToolPattern: "^(Read|Glob|Grep|WebSearch|WebFetch)$", Action: "Allow", Risk: "Low" },
|
|
528
|
+
{ Name: "Allow safe bash commands", ToolPattern: "^Bash$", CommandPattern: "^(npm test|npm run lint|npm run build|git status|git diff|git log|ls)", Action: "Allow", Risk: "Low" },
|
|
529
|
+
{ Name: "Gate file writes", ToolPattern: "^(Edit|Write|NotebookEdit)$", Action: "Gate", Risk: "Medium" },
|
|
530
|
+
{ Name: "Gate package installs", ToolPattern: "^Bash$", CommandPattern: "(npm install|pip install|dotnet add)", Action: "Gate", Risk: "Medium" },
|
|
531
|
+
{ Name: "Deny destructive commands", ToolPattern: "^Bash$", CommandPattern: "(rm -rf|chmod 777|curl.*\\\\|.*bash)", Action: "Deny", Risk: "High" },
|
|
532
|
+
{ Name: "Default — gate everything else", ToolPattern: ".*", Action: "Gate", Risk: "Low" },
|
|
533
|
+
],
|
|
534
|
+
permissive: [
|
|
535
|
+
{ Name: "Deny destructive commands", ToolPattern: "^Bash$", CommandPattern: "(rm -rf|chmod 777|curl.*\\\\|.*bash)", Action: "Deny", Risk: "High" },
|
|
536
|
+
{ Name: "Default — allow everything else", ToolPattern: ".*", Action: "Allow", Risk: "Low" },
|
|
537
|
+
],
|
|
538
|
+
"ci-safe": [
|
|
539
|
+
{ Name: "Allow read-only tools", ToolPattern: "^(Read|Glob|Grep)$", Action: "Allow", Risk: "Low" },
|
|
540
|
+
{ Name: "Allow test and build commands", ToolPattern: "^Bash$", CommandPattern: "^(npm test|npm run lint|npm run build)", Action: "Allow", Risk: "Low" },
|
|
541
|
+
{ Name: "Deny all bash", ToolPattern: "^Bash$", Action: "Deny", Risk: "High" },
|
|
542
|
+
{ Name: "Default — deny everything else", ToolPattern: ".*", Action: "Deny", Risk: "Medium" },
|
|
543
|
+
],
|
|
544
|
+
};
|
|
545
|
+
async function template() {
|
|
546
|
+
const TEMPLATES = Object.keys(TEMPLATE_RULES);
|
|
413
547
|
const templateName = process.argv[3];
|
|
414
548
|
if (!templateName) {
|
|
415
549
|
console.log(`
|
|
@@ -417,7 +551,7 @@ function template() {
|
|
|
417
551
|
|
|
418
552
|
Available templates:
|
|
419
553
|
conservative Gate writes and bash, deny destructive commands
|
|
420
|
-
moderate Allow safe writes and bash, gate the rest
|
|
554
|
+
moderate Allow safe writes and bash, gate the rest (default)
|
|
421
555
|
permissive Allow everything, deny only destructive commands
|
|
422
556
|
ci-safe Minimal permissions for CI/CD environments
|
|
423
557
|
|
|
@@ -434,26 +568,21 @@ Example:
|
|
|
434
568
|
console.error(`[askance] Available templates: ${TEMPLATES.join(", ")}`);
|
|
435
569
|
process.exit(1);
|
|
436
570
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const distTemplatePath = path_1.default.join(pkgRoot, "dist", "templates", `${templateName}.yml`);
|
|
442
|
-
const srcTemplatePath = path_1.default.join(pkgRoot, "src", "templates", `${templateName}.yml`);
|
|
443
|
-
const templatePath = fs_1.default.existsSync(distTemplatePath) ? distTemplatePath : srcTemplatePath;
|
|
444
|
-
if (!fs_1.default.existsSync(templatePath)) {
|
|
445
|
-
console.error(`[askance] Template file not found at ${templatePath}`);
|
|
446
|
-
console.error("[askance] Try rebuilding: npm run build");
|
|
571
|
+
const config = (0, api_client_1.readConfig)();
|
|
572
|
+
if (!config.token || !config.projectId) {
|
|
573
|
+
console.error("[askance] You must be logged in and have a project linked.");
|
|
574
|
+
console.error(" Run: npx askance login");
|
|
447
575
|
process.exit(1);
|
|
448
576
|
}
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
577
|
+
const rules = TEMPLATE_RULES[templateName];
|
|
578
|
+
console.log(`[askance] Applying "${templateName}" template (${rules.length} rules)...`);
|
|
579
|
+
const result = await (0, api_client_1.apiPost)(`/api/projects/${config.projectId}/rules`, rules);
|
|
580
|
+
if (!result.ok) {
|
|
581
|
+
console.error(`[askance] Failed to apply template: ${result.error}`);
|
|
582
|
+
process.exit(1);
|
|
453
583
|
}
|
|
454
|
-
|
|
455
|
-
console.log(
|
|
456
|
-
console.log("[askance] Restart your agent session to apply the new policy");
|
|
584
|
+
console.log(`[askance] Applied "${templateName}" template to project rules`);
|
|
585
|
+
console.log("[askance] Rules are active immediately — no restart needed");
|
|
457
586
|
}
|
|
458
587
|
function help() {
|
|
459
588
|
console.log(`
|
|
@@ -464,7 +593,7 @@ Usage:
|
|
|
464
593
|
|
|
465
594
|
Commands:
|
|
466
595
|
init Set up Askance in the current project
|
|
467
|
-
- Creates .askance.
|
|
596
|
+
- Creates .askance/config.json (settings)
|
|
468
597
|
- Configures agent hooks and MCP servers
|
|
469
598
|
|
|
470
599
|
Options:
|
|
@@ -477,7 +606,8 @@ Commands:
|
|
|
477
606
|
- Opens browser for OAuth sign-in
|
|
478
607
|
- Saves access token to .askance/credentials
|
|
479
608
|
|
|
480
|
-
template Apply a pre-built policy template
|
|
609
|
+
template Apply a pre-built policy template to your project
|
|
610
|
+
- Pushes rules to the Askance API (requires login)
|
|
481
611
|
- Available: conservative, moderate, permissive, ci-safe
|
|
482
612
|
- Usage: npx askance template <name>
|
|
483
613
|
|
|
@@ -497,19 +627,25 @@ Dashboard: https://app.askance.app
|
|
|
497
627
|
Docs: https://askance.app
|
|
498
628
|
`);
|
|
499
629
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
init
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
login
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
template
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
630
|
+
async function main() {
|
|
631
|
+
switch (command) {
|
|
632
|
+
case "init":
|
|
633
|
+
init();
|
|
634
|
+
break;
|
|
635
|
+
case "login":
|
|
636
|
+
await login();
|
|
637
|
+
break;
|
|
638
|
+
case "template":
|
|
639
|
+
await template();
|
|
640
|
+
break;
|
|
641
|
+
case "help":
|
|
642
|
+
default:
|
|
643
|
+
help();
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
514
646
|
}
|
|
647
|
+
main().catch((err) => {
|
|
648
|
+
console.error("[askance] Error:", err.message);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
});
|
|
515
651
|
//# sourceMappingURL=index.js.map
|
|
@@ -15,23 +15,22 @@ const node_https_1 = __importDefault(require("node:https"));
|
|
|
15
15
|
const node_http_1 = __importDefault(require("node:http"));
|
|
16
16
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
17
|
const node_path_1 = __importDefault(require("node:path"));
|
|
18
|
-
const yaml_1 = require("yaml");
|
|
19
18
|
let cachedConfig = null;
|
|
20
19
|
function readConfig() {
|
|
21
20
|
if (cachedConfig)
|
|
22
21
|
return cachedConfig;
|
|
23
22
|
const projectDir = process.env.ASKANCE_PROJECT_DIR ?? process.cwd();
|
|
24
|
-
// Read
|
|
23
|
+
// Read from .askance/config.json (primary) or env
|
|
25
24
|
let apiUrl = process.env.ASKANCE_API_URL ?? "";
|
|
26
25
|
let projectId = process.env.ASKANCE_PROJECT_ID ?? "";
|
|
27
|
-
const
|
|
28
|
-
if (node_fs_1.default.existsSync(
|
|
26
|
+
const configPath = node_path_1.default.join(projectDir, ".askance", "config.json");
|
|
27
|
+
if (node_fs_1.default.existsSync(configPath)) {
|
|
29
28
|
try {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
apiUrl =
|
|
33
|
-
if (
|
|
34
|
-
projectId =
|
|
29
|
+
const cfg = JSON.parse(node_fs_1.default.readFileSync(configPath, "utf-8"));
|
|
30
|
+
if (cfg.apiUrl && !apiUrl)
|
|
31
|
+
apiUrl = cfg.apiUrl;
|
|
32
|
+
if (cfg.projectId && !projectId)
|
|
33
|
+
projectId = cfg.projectId;
|
|
35
34
|
}
|
|
36
35
|
catch { }
|
|
37
36
|
}
|
|
@@ -170,13 +169,13 @@ function waitForInstruction(projectId, timeoutMs) {
|
|
|
170
169
|
}
|
|
171
170
|
function readKeepAliveConfig() {
|
|
172
171
|
const projectDir = process.env.ASKANCE_PROJECT_DIR ?? process.cwd();
|
|
173
|
-
const
|
|
172
|
+
const configPath = node_path_1.default.join(projectDir, ".askance", "config.json");
|
|
174
173
|
try {
|
|
175
|
-
const
|
|
174
|
+
const cfg = JSON.parse(node_fs_1.default.readFileSync(configPath, "utf-8"));
|
|
176
175
|
return {
|
|
177
|
-
enabled:
|
|
178
|
-
duration:
|
|
179
|
-
poll_interval:
|
|
176
|
+
enabled: cfg.keepAlive?.enabled ?? true,
|
|
177
|
+
duration: cfg.keepAlive?.duration ?? 3600,
|
|
178
|
+
poll_interval: cfg.keepAlive?.pollInterval ?? 30,
|
|
180
179
|
};
|
|
181
180
|
}
|
|
182
181
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askance/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Askance CLI — Tool call interception & approval management for AI coding agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://askance.app",
|
|
@@ -29,19 +29,17 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist/**/*.js",
|
|
32
|
-
"dist/templates/**",
|
|
33
32
|
"!dist/daemon/**",
|
|
34
33
|
"!dist/dashboard/**",
|
|
35
34
|
"LICENSE",
|
|
36
35
|
"README.md"
|
|
37
36
|
],
|
|
38
37
|
"scripts": {
|
|
39
|
-
"build": "tsc
|
|
38
|
+
"build": "tsc"
|
|
40
39
|
},
|
|
41
40
|
"dependencies": {
|
|
42
41
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
43
|
-
"uuid": "^11.1.0"
|
|
44
|
-
"yaml": "^2.6.1"
|
|
42
|
+
"uuid": "^11.1.0"
|
|
45
43
|
},
|
|
46
44
|
"devDependencies": {
|
|
47
45
|
"@types/uuid": "^10.0.0",
|
|
@@ -1,22 +0,0 @@
|
|
|
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
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"cursor.hooks": {
|
|
3
|
-
"preToolUse": {
|
|
4
|
-
"command": "node",
|
|
5
|
-
"args": ["node_modules/@askance/cli/dist/hook-handler/index.js"],
|
|
6
|
-
"matchTools": [".*"],
|
|
7
|
-
"excludeTools": ["mcp__askance__.*"]
|
|
8
|
-
}
|
|
9
|
-
},
|
|
10
|
-
"cursor.mcp": {
|
|
11
|
-
"servers": {
|
|
12
|
-
"askance": {
|
|
13
|
-
"command": "node",
|
|
14
|
-
"args": ["node_modules/@askance/cli/dist/mcp-server/index.js"]
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
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: "Allow safe writes"
|
|
16
|
-
match: { tool: "^(Write|Edit|NotebookEdit)$" }
|
|
17
|
-
action: allow
|
|
18
|
-
risk: low
|
|
19
|
-
- name: "Allow safe bash commands"
|
|
20
|
-
match: { tool: "^Bash$", command: "^(npm test|npm run |npx tsc|git status|git log|git diff|ls |cat |echo |pwd)" }
|
|
21
|
-
action: allow
|
|
22
|
-
risk: low
|
|
23
|
-
- name: "Gate other bash commands"
|
|
24
|
-
match: { tool: "^Bash$" }
|
|
25
|
-
action: gate
|
|
26
|
-
risk: medium
|
|
27
|
-
- name: "Deny destructive commands"
|
|
28
|
-
match: { tool: "^Bash$", command: "(rm -rf|drop table|format |shutdown|reboot|git push --force)" }
|
|
29
|
-
action: deny
|
|
30
|
-
risk: high
|
|
31
|
-
- name: "Default - gate everything"
|
|
32
|
-
match: { tool: ".*" }
|
|
33
|
-
action: gate
|
|
34
|
-
risk: low
|
|
@@ -1,18 +0,0 @@
|
|
|
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 all tools"
|
|
12
|
-
match: { tool: ".*" }
|
|
13
|
-
action: allow
|
|
14
|
-
risk: low
|
|
15
|
-
- name: "Deny destructive commands"
|
|
16
|
-
match: { tool: "^Bash$", command: "(rm -rf /|drop database|format c:|shutdown|reboot|git push --force.*main)" }
|
|
17
|
-
action: deny
|
|
18
|
-
risk: high
|