Strengthen built-in default policy
This commit is contained in:
@@ -9,12 +9,17 @@ It classifies operations into four outcomes:
|
|||||||
- **deny** — block outright
|
- **deny** — block outright
|
||||||
- **refine** — block and tell the model how to reformulate the command safely
|
- **refine** — block and tell the model how to reformulate the command safely
|
||||||
|
|
||||||
The default policy is opinionated but usable:
|
The default policy is opinionated but usable, and it is now **baked into the code** even if you provide no config file.
|
||||||
|
|
||||||
|
Built-in defaults:
|
||||||
|
|
||||||
- `sudo` and `ssh` are **not denied by default**
|
- `sudo` and `ssh` are **not denied by default**
|
||||||
- risky commands are generally **confirmed**
|
- risky commands are generally **confirmed**
|
||||||
- only objectively dangerous or ambiguous forms are **denied/refined**
|
- only objectively dangerous or ambiguous forms are **denied/refined**
|
||||||
- destructive deletes should use an **explicit absolute path**
|
- destructive deletes should use an **explicit absolute path**
|
||||||
|
- `curl` / `wget` / `nc` / `socat` / `telnet` are **confirmed**
|
||||||
|
- `env` / `printenv` are **confirmed**
|
||||||
|
- bash-based writes to sensitive files such as `AGENTS.md`, `.env`, shell dotfiles, SSH config, and local policy files are **confirmed**
|
||||||
|
|
||||||
## What it protects against
|
## What it protects against
|
||||||
|
|
||||||
@@ -27,9 +32,12 @@ Examples of behavior it catches by default:
|
|||||||
- `find . -delete`
|
- `find . -delete`
|
||||||
- `sudo ...` (confirmation)
|
- `sudo ...` (confirmation)
|
||||||
- `ssh ...` / `scp` / `rsync` / `sftp` (confirmation)
|
- `ssh ...` / `scp` / `rsync` / `sftp` (confirmation)
|
||||||
|
- `curl ...`, `wget ...`, `nc ...`, `socat ...` (confirmation)
|
||||||
|
- `env`, `printenv` (confirmation)
|
||||||
- `git reset --hard`, `git clean -fdx`, `git push` (confirmation)
|
- `git reset --hard`, `git clean -fdx`, `git push` (confirmation)
|
||||||
|
- `sed -i ~/.bashrc ...` or `tee AGENTS.md` (confirmation)
|
||||||
- writes outside the workspace (confirmation)
|
- writes outside the workspace (confirmation)
|
||||||
- reads/writes to sensitive paths such as `~/.ssh` or `.env` (confirmation)
|
- reads/writes to sensitive paths such as `~/.ssh`, `AGENTS.md`, or `.env` (confirmation)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -38,7 +46,7 @@ Examples of behavior it catches by default:
|
|||||||
Private Gitea/GitHub style install:
|
Private Gitea/GitHub style install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pi install git:git@git.enne2.net:enne2/pi-policy-gate.git
|
pi install 'ssh://git@git.enne2.net:222/enne2/pi-policy-gate.git'
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with HTTPS:
|
Or with HTTPS:
|
||||||
@@ -50,7 +58,7 @@ pi install https://git.enne2.net/enne2/pi-policy-gate.git
|
|||||||
### With npm from the git repository
|
### With npm from the git repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install git+ssh://git@git.enne2.net/enne2/pi-policy-gate.git
|
npm install git+ssh://git@git.enne2.net:222/enne2/pi-policy-gate.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Then load it from local `node_modules` or publish it to your npm registry later.
|
Then load it from local `node_modules` or publish it to your npm registry later.
|
||||||
|
|||||||
@@ -61,17 +61,63 @@ const DEFAULT_SENSITIVE_PATH_GLOBS = [
|
|||||||
"~/.config/gcloud/**",
|
"~/.config/gcloud/**",
|
||||||
"~/.azure/**",
|
"~/.azure/**",
|
||||||
"~/.gnupg/**",
|
"~/.gnupg/**",
|
||||||
|
"~/.docker/**",
|
||||||
|
"~/.kube/**",
|
||||||
|
"~/.config/gh/**",
|
||||||
"~/.pi/**",
|
"~/.pi/**",
|
||||||
|
"**/.pi/**",
|
||||||
"**/.env",
|
"**/.env",
|
||||||
"**/.env.*",
|
"**/.env.*",
|
||||||
"**/*.pem",
|
"**/*.pem",
|
||||||
"**/*.key",
|
"**/*.key",
|
||||||
"**/.netrc",
|
"**/.netrc",
|
||||||
"**/.git-credentials",
|
"**/.git-credentials",
|
||||||
|
"~/.gitconfig",
|
||||||
|
"~/.wgetrc",
|
||||||
|
"~/.curlrc",
|
||||||
|
"~/.npmrc",
|
||||||
|
"~/.pypirc",
|
||||||
"~/.bashrc",
|
"~/.bashrc",
|
||||||
"~/.zshrc",
|
"~/.zshrc",
|
||||||
"~/.profile",
|
"~/.profile",
|
||||||
"~/.bash_profile",
|
"~/.bash_profile",
|
||||||
|
"~/.local/bin/**",
|
||||||
|
"~/.config/systemd/**",
|
||||||
|
"**/.git/config",
|
||||||
|
"**/.git/hooks/**",
|
||||||
|
"**/AGENTS.md",
|
||||||
|
"**/CLAUDE.md",
|
||||||
|
"**/.cursorrules",
|
||||||
|
"**/copilot-instructions.md",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "fish"]);
|
||||||
|
const REMOTE_COMMANDS = new Set(["ssh", "scp", "sftp", "rsync", "mosh"]);
|
||||||
|
const NETWORK_COMMANDS = new Set(["curl", "wget", "nc", "ncat", "netcat", "socat", "telnet"]);
|
||||||
|
const PACKAGE_MANAGER_COMMANDS = new Set(["dnf", "yum", "apt", "apt-get", "zypper", "pacman"]);
|
||||||
|
const PLATFORM_CONTROL_COMMANDS = new Set(["docker", "podman", "kubectl", "helm", "mount", "umount", "dd", "crontab", "at"]);
|
||||||
|
const PATH_AWARE_WRITE_COMMANDS = new Set(["cp", "mv", "install", "ln", "touch", "mkdir", "tee", "truncate"]);
|
||||||
|
const PATH_AWARE_READ_COMMANDS = new Set(["cat", "less", "more", "head", "tail", "file", "stat"]);
|
||||||
|
const SENSITIVE_BASENAMES = new Set([
|
||||||
|
"AGENTS.md",
|
||||||
|
"CLAUDE.md",
|
||||||
|
".cursorrules",
|
||||||
|
"copilot-instructions.md",
|
||||||
|
".env",
|
||||||
|
".netrc",
|
||||||
|
".git-credentials",
|
||||||
|
".bashrc",
|
||||||
|
".zshrc",
|
||||||
|
".profile",
|
||||||
|
".bash_profile",
|
||||||
|
"config",
|
||||||
|
]);
|
||||||
|
const DEFAULT_POLICY_FEATURES = [
|
||||||
|
"confirm sudo, ssh/scp/rsync/sftp, service changes, package-manager mutations, network clients, and destructive git",
|
||||||
|
"deny only objectively unsafe forms such as wildcard recursive deletes or safety-wrapper bypass attempts",
|
||||||
|
"refine recursive deletes and find -delete into explicit absolute paths",
|
||||||
|
"confirm reads/writes against sensitive files and writes outside the workspace",
|
||||||
|
"confirm bash-based file mutations targeting sensitive paths such as AGENTS.md, .env, shell dotfiles, SSH keys, and local policy files",
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_CONFIG: ResolvedConfig = {
|
const DEFAULT_CONFIG: ResolvedConfig = {
|
||||||
@@ -168,6 +214,111 @@ function firstNonOption(tokens: string[], start = 0): number {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
return Array.from(new Set(values.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractShellCodeArg(args: string[]): string | null {
|
||||||
|
const flagIndex = args.findIndex((token) => token === "-c");
|
||||||
|
if (flagIndex >= 0 && args[flagIndex + 1]) {
|
||||||
|
return args[flagIndex + 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyUrl(token: string): boolean {
|
||||||
|
return /^[a-z][a-z0-9+.-]*:\/\//i.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikePathToken(token: string): boolean {
|
||||||
|
if (!token || token === "-" || isLikelyUrl(token)) return false;
|
||||||
|
return (
|
||||||
|
token.startsWith("/") ||
|
||||||
|
token.startsWith("./") ||
|
||||||
|
token.startsWith("../") ||
|
||||||
|
token.startsWith("~/") ||
|
||||||
|
token === "." ||
|
||||||
|
token === ".." ||
|
||||||
|
token.includes("/") ||
|
||||||
|
SENSITIVE_BASENAMES.has(token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRedirectionTargets(command: string): string[] {
|
||||||
|
const targets: string[] = [];
|
||||||
|
const regex = /(?:^|\s)(?:\d*>>?|<<?)\s*([^\s|&;]+)/g;
|
||||||
|
for (const match of command.matchAll(regex)) {
|
||||||
|
const target = stripOuterQuotes(match[1] ?? "");
|
||||||
|
if (looksLikePathToken(target)) targets.push(target);
|
||||||
|
}
|
||||||
|
return uniqueStrings(targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCandidatePathTokens(commandName: string, args: string[], rawCommand: string): string[] {
|
||||||
|
const candidates = args.filter((token) => !token.startsWith("-") && looksLikePathToken(token));
|
||||||
|
if (commandName === "tee") {
|
||||||
|
return uniqueStrings([...candidates, ...extractRedirectionTargets(rawCommand)]);
|
||||||
|
}
|
||||||
|
return uniqueStrings([...candidates, ...extractRedirectionTargets(rawCommand)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPathAssessment(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig) {
|
||||||
|
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath));
|
||||||
|
const inWorkspace = config.workspaceRoots.some((root) => isWithinRoot(absPath, root));
|
||||||
|
const sensitive = isSensitivePath(absPath, cwd, config);
|
||||||
|
const details = [
|
||||||
|
`Resolved path: ${summarizePath(absPath, cwd)}`,
|
||||||
|
`Workspace roots: ${summarizeRoots(config, cwd).join(", ")}`,
|
||||||
|
];
|
||||||
|
return { absPath, inWorkspace, sensitive, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPathAccessFromBash(
|
||||||
|
mode: "read" | "write",
|
||||||
|
commandName: string,
|
||||||
|
pathTokens: string[],
|
||||||
|
cwd: string,
|
||||||
|
config: ResolvedConfig,
|
||||||
|
): Decision | null {
|
||||||
|
if (pathTokens.length === 0) return null;
|
||||||
|
const assessments = pathTokens.map((token) => ({ token, ...extractPathAssessment(mode, token, cwd, config) }));
|
||||||
|
|
||||||
|
if (mode === "read") {
|
||||||
|
const sensitive = assessments.filter((item) => item.sensitive);
|
||||||
|
if (sensitive.length > 0 && config.confirmSensitiveReads) {
|
||||||
|
return makeDecision(
|
||||||
|
"confirm",
|
||||||
|
`Confirm ${commandName} sensitive read`,
|
||||||
|
`${commandName} reads a sensitive path.`,
|
||||||
|
sensitive.flatMap((item) => [`Argument: ${item.token}`, ...item.details]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensitiveWrites = assessments.filter((item) => item.sensitive);
|
||||||
|
if (sensitiveWrites.length > 0 && config.confirmSensitiveWrites) {
|
||||||
|
return makeDecision(
|
||||||
|
"confirm",
|
||||||
|
`Confirm ${commandName} sensitive write`,
|
||||||
|
`${commandName} may modify a sensitive path.`,
|
||||||
|
sensitiveWrites.flatMap((item) => [`Argument: ${item.token}`, ...item.details]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outsideWorkspace = assessments.filter((item) => !item.inWorkspace);
|
||||||
|
if (outsideWorkspace.length > 0 && config.confirmWritesOutsideWorkspace) {
|
||||||
|
return makeDecision(
|
||||||
|
"confirm",
|
||||||
|
`Confirm ${commandName} write outside workspace`,
|
||||||
|
`${commandName} may modify a path outside the configured workspace roots.`,
|
||||||
|
outsideWorkspace.flatMap((item) => [`Argument: ${item.token}`, ...item.details]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function extractPrimaryCommand(tokens: string[]): {
|
function extractPrimaryCommand(tokens: string[]): {
|
||||||
command: string | null;
|
command: string | null;
|
||||||
args: string[];
|
args: string[];
|
||||||
@@ -542,36 +693,62 @@ function classifyBash(command: string, cwd: string, config: ResolvedConfig): Dec
|
|||||||
"Confirm sudo command",
|
"Confirm sudo command",
|
||||||
"sudo requires explicit human review.",
|
"sudo requires explicit human review.",
|
||||||
[`Nested command: ${primary.command} ${primary.args.join(" ")}`.trim()],
|
[`Nested command: ${primary.command} ${primary.args.join(" ")}`.trim()],
|
||||||
|
nestedDecision.action === "confirm" ? nestedDecision.suggest : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (SHELL_WRAPPERS.has(cmd)) {
|
||||||
|
const nestedCode = extractShellCodeArg(primary.args);
|
||||||
|
if (nestedCode) {
|
||||||
|
return classifyBash(nestedCode, cwd, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd === "rm") return classifyRm(primary.args, cwd, config);
|
if (cmd === "rm") return classifyRm(primary.args, cwd, config);
|
||||||
if (cmd === "find") return classifyFind(primary.args, cwd, config);
|
if (cmd === "find") return classifyFind(primary.args, cwd, config);
|
||||||
if (cmd === "git") return classifyGit(primary.args);
|
if (cmd === "git") return classifyGit(primary.args);
|
||||||
if (cmd === "systemctl") return classifySystemCtl(primary.args);
|
if (cmd === "systemctl") return classifySystemCtl(primary.args);
|
||||||
if (["dnf", "yum", "apt", "apt-get", "zypper", "pacman"].includes(cmd)) {
|
if (PACKAGE_MANAGER_COMMANDS.has(cmd)) {
|
||||||
return classifyPackageManager(cmd, primary.args);
|
return classifyPackageManager(cmd, primary.args);
|
||||||
}
|
}
|
||||||
if (["chmod", "chown"].includes(cmd)) {
|
if (["chmod", "chown"].includes(cmd)) {
|
||||||
return classifyChmodLike(cmd, primary.args);
|
return classifyChmodLike(cmd, primary.args);
|
||||||
}
|
}
|
||||||
if (["ssh", "scp", "sftp", "rsync", "mosh"].includes(cmd)) {
|
if (REMOTE_COMMANDS.has(cmd)) {
|
||||||
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect remote systems or transfer data.`);
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect remote systems or transfer data.`);
|
||||||
}
|
}
|
||||||
if (["docker", "podman", "kubectl", "helm", "mount", "umount", "dd"].includes(cmd)) {
|
if (NETWORK_COMMANDS.has(cmd)) {
|
||||||
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect containers, clusters, devices, or mounts.`);
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can reach external systems or open raw network channels.`);
|
||||||
|
}
|
||||||
|
if (["env", "printenv"].includes(cmd)) {
|
||||||
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can reveal credentials or sensitive environment data.`);
|
||||||
|
}
|
||||||
|
if (PLATFORM_CONTROL_COMMANDS.has(cmd)) {
|
||||||
|
return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect containers, clusters, scheduled tasks, devices, or mounts.`);
|
||||||
|
}
|
||||||
|
if (cmd === "sed" && primary.args.some((token) => token === "-i" || token.startsWith("-i"))) {
|
||||||
|
const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config);
|
||||||
|
if (pathDecision) return pathDecision;
|
||||||
|
return makeDecision("confirm", "Confirm sed -i", "In-place sed edit requested.");
|
||||||
|
}
|
||||||
|
if (cmd === "perl" && primary.args.some((token) => token === "-pi" || token.startsWith("-pi"))) {
|
||||||
|
const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config);
|
||||||
|
if (pathDecision) return pathDecision;
|
||||||
|
return makeDecision("confirm", "Confirm perl -pi", "In-place perl edit requested.");
|
||||||
|
}
|
||||||
|
if (PATH_AWARE_WRITE_COMMANDS.has(cmd)) {
|
||||||
|
const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config);
|
||||||
|
if (pathDecision) return pathDecision;
|
||||||
|
}
|
||||||
|
if (PATH_AWARE_READ_COMMANDS.has(cmd)) {
|
||||||
|
const pathDecision = classifyPathAccessFromBash("read", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config);
|
||||||
|
if (pathDecision) return pathDecision;
|
||||||
}
|
}
|
||||||
return makeDecision("allow", "Allow bash", "No risky pattern matched by the default policy.");
|
return makeDecision("allow", "Allow bash", "No risky pattern matched by the default policy.");
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyPathTool(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig): Decision {
|
function classifyPathTool(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig): Decision {
|
||||||
const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath));
|
const { inWorkspace, sensitive, details } = extractPathAssessment(tool, rawPath, cwd, config);
|
||||||
const inWorkspace = config.workspaceRoots.some((root) => isWithinRoot(absPath, root));
|
|
||||||
const sensitive = isSensitivePath(absPath, cwd, config);
|
|
||||||
const details = [
|
|
||||||
`Resolved path: ${summarizePath(absPath, cwd)}`,
|
|
||||||
`Workspace roots: ${summarizeRoots(config, cwd).join(", ")}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (tool === "read") {
|
if (tool === "read") {
|
||||||
if (sensitive && config.confirmSensitiveReads) {
|
if (sensitive && config.confirmSensitiveReads) {
|
||||||
@@ -628,7 +805,8 @@ export default function policyGate(pi: ExtensionAPI) {
|
|||||||
`- Workspace roots currently protected: ${roots}.\n` +
|
`- Workspace roots currently protected: ${roots}.\n` +
|
||||||
"- For recursive or forceful deletes, use an explicit absolute path, never wildcards or broad targets.\n" +
|
"- For recursive or forceful deletes, use an explicit absolute path, never wildcards or broad targets.\n" +
|
||||||
"- If a command is blocked or requires refinement, propose a safer reformulation instead of trying to bypass the gate.\n" +
|
"- If a command is blocked or requires refinement, propose a safer reformulation instead of trying to bypass the gate.\n" +
|
||||||
"- Treat sudo, ssh, rsync, scp, service changes, and destructive git operations as review-worthy actions.\n",
|
"- Treat sudo, ssh, rsync, scp, service changes, destructive git operations, raw network clients, and environment dumps as review-worthy actions.\n" +
|
||||||
|
"- Be especially careful with AGENTS.md, CLAUDE.md, .env files, shell dotfiles, SSH keys, and local policy files.\n",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -644,6 +822,8 @@ export default function policyGate(pi: ExtensionAPI) {
|
|||||||
`Sensitive writes require confirm: ${activeConfig.confirmSensitiveWrites}`,
|
`Sensitive writes require confirm: ${activeConfig.confirmSensitiveWrites}`,
|
||||||
`Writes outside workspace require confirm: ${activeConfig.confirmWritesOutsideWorkspace}`,
|
`Writes outside workspace require confirm: ${activeConfig.confirmWritesOutsideWorkspace}`,
|
||||||
`Recursive delete requires absolute path: ${activeConfig.requireAbsolutePathForRecursiveDelete}`,
|
`Recursive delete requires absolute path: ${activeConfig.requireAbsolutePathForRecursiveDelete}`,
|
||||||
|
"Built-in default policy:",
|
||||||
|
...DEFAULT_POLICY_FEATURES.map((feature) => `- ${feature}`),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
if (ctx.hasUI) ctx.ui.notify(summary, "info");
|
if (ctx.hasUI) ctx.ui.notify(summary, "info");
|
||||||
else console.log(summary);
|
else console.log(summary);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@enne2/pi-policy-gate",
|
"name": "@enne2/pi-policy-gate",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@enne2/pi-policy-gate",
|
"name": "@enne2/pi-policy-gate",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^10.1.1"
|
"minimatch": "^10.1.1"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@enne2/pi-policy-gate",
|
"name": "@enne2/pi-policy-gate",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
|
"description": "Policy gate extension for pi that classifies risky tool calls into allow, confirm, deny, or require-refinement.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
Reference in New Issue
Block a user