@andares/pi-toolkits 0.8.0 → 0.9.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/README.md +66 -2
- package/README.zh-CN.md +65 -2
- package/package.json +7 -3
- package/src/features/ask/index.test.ts +118 -0
- package/src/features/ask/index.ts +31 -13
- package/src/features/autostart/autostart.test.ts +45 -2
- package/src/features/autostart/autostart.ts +4 -2
- package/src/features/autostart/index.ts +100 -48
- package/src/features/permission/constants.test.ts +60 -0
- package/src/features/permission/constants.ts +158 -0
- package/src/features/permission/index.test.ts +340 -0
- package/src/features/permission/index.ts +333 -0
- package/src/features/permission/modes.test.ts +66 -0
- package/src/features/permission/modes.ts +82 -0
- package/src/features/permission/panel.ts +118 -0
- package/src/features/permission/paths.test.ts +111 -0
- package/src/features/permission/paths.ts +126 -0
- package/src/features/permission/policy.test.ts +237 -0
- package/src/features/permission/policy.ts +338 -0
- package/src/features/permission/prompt.ts +146 -0
- package/src/features/permission/session-rules.ts +99 -0
- package/src/features/permission/units.test.ts +205 -0
- package/src/features/permission/units.ts +995 -0
- package/src/index.ts +15 -2
- package/src/lib/gates.test.ts +171 -0
- package/src/lib/gates.ts +153 -0
|
@@ -1,81 +1,120 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* autostart feature — run
|
|
2
|
+
* autostart feature — run declared slash commands at session start.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the schema). Execution gates, in order:
|
|
4
|
+
* Two sources, executed in order, never merged:
|
|
6
5
|
*
|
|
7
|
-
* 1.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* 1. `<agentDir>/autostart.toml` — the operator's own defaults. Runs in every
|
|
7
|
+
* project; no trust gate, because the file lives in pi's config directory
|
|
8
|
+
* and is not controlled by the project.
|
|
9
|
+
* 2. `<project root>/.pi/autostart.toml` — project-declared commands. Gated on
|
|
10
|
+
* `ctx.isProjectTrusted()`: an untrusted repository must not execute
|
|
11
|
+
* anything.
|
|
12
|
+
*
|
|
13
|
+
* Execution gates, in order:
|
|
14
|
+
*
|
|
15
|
+
* - reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
|
|
16
|
+
* - config file exists — a missing file is a silent opt-in skip
|
|
17
|
+
* - project trust (project source only)
|
|
18
|
+
* - per-command pre-validation against `pi.getCommands()`: only commands
|
|
19
|
+
* registered by an *extension* are dispatched. This is mandatory:
|
|
20
|
+
* sendUserMessage with an unhandled "/x" falls through prompt() to the
|
|
21
|
+
* model as a plain user message (pi agent-session.js), so an unknown
|
|
22
|
+
* command must be skipped here, never sent.
|
|
15
23
|
*
|
|
16
24
|
* Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
|
|
17
25
|
* that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
|
|
18
|
-
* instead of the model prompt path. The wrapper defaults it to false
|
|
19
|
-
* (each byte-identical across every published version of the supported
|
|
20
|
-
* window, 1.0.0 → 1.3.0; see AGENTS.md host-compat
|
|
21
|
-
*
|
|
26
|
+
* instead of the model prompt path. The wrapper defaults it to false in this
|
|
27
|
+
* package (each byte-identical across every published version of the supported
|
|
28
|
+
* window, 1.0.0 → 1.3.0; see AGENTS.md host-compat discipline for the version
|
|
29
|
+
* matrix).
|
|
22
30
|
*
|
|
23
31
|
* Only extension commands can run this way. Built-in commands (/compact,
|
|
24
32
|
* /reload, /resume, …) are dispatched at the TUI layer and cannot be
|
|
25
33
|
* triggered from the extension layer; skill commands (/skill:name) expand
|
|
26
|
-
* into prompts rather than execute. First-party use
|
|
27
|
-
* (pi-add-dir
|
|
34
|
+
* into prompts rather than execute. First-party use cases: `/add-dir`
|
|
35
|
+
* (pi-add-dir, idempotent) and `/ask-perm` (session-scoped permission).
|
|
28
36
|
*/
|
|
29
37
|
import { readFile } from "node:fs/promises";
|
|
30
38
|
import { join } from "node:path";
|
|
31
|
-
import
|
|
39
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
40
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
32
41
|
import {
|
|
33
42
|
AUTOSTART_CONFIG_PATH,
|
|
34
43
|
AutostartParseError,
|
|
35
44
|
commandNameOf,
|
|
45
|
+
GLOBAL_AUTOSTART_FILENAME,
|
|
36
46
|
parseAutostartConfig,
|
|
37
47
|
RUNNABLE_REASONS,
|
|
38
48
|
} from "./autostart.js";
|
|
39
49
|
|
|
40
|
-
export
|
|
41
|
-
pi
|
|
42
|
-
|
|
50
|
+
export interface AutostartOptions {
|
|
51
|
+
/** pi's config directory; defaults to `getAgentDir()`. */
|
|
52
|
+
readonly agentDir?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Source {
|
|
56
|
+
readonly path: string;
|
|
57
|
+
readonly label: string;
|
|
58
|
+
/** Project sources require trust; the operator's global file does not. */
|
|
59
|
+
readonly requiresTrust: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function messageOf(error: unknown): string {
|
|
63
|
+
return error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function registerAutostart(
|
|
67
|
+
pi: ExtensionAPI,
|
|
68
|
+
options: AutostartOptions = {},
|
|
69
|
+
): void {
|
|
70
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
43
71
|
|
|
72
|
+
async function readCommands(
|
|
73
|
+
ctx: ExtensionContext,
|
|
74
|
+
source: Source,
|
|
75
|
+
): Promise<string[] | undefined> {
|
|
44
76
|
let text: string;
|
|
45
77
|
try {
|
|
46
|
-
text = await readFile(
|
|
78
|
+
text = await readFile(source.path, "utf8");
|
|
47
79
|
} catch (err) {
|
|
48
80
|
// Missing file = opt-in skip. Any other read failure (EACCES, EISDIR,
|
|
49
81
|
// …) is a misconfiguration the operator should hear about.
|
|
50
82
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
51
83
|
ctx.ui.notify(
|
|
52
|
-
`autostart: skipped — cannot read ${
|
|
84
|
+
`autostart: skipped ${source.label} — cannot read ${source.path}: ${messageOf(err)}`,
|
|
53
85
|
"warning",
|
|
54
86
|
);
|
|
55
87
|
}
|
|
56
|
-
return;
|
|
88
|
+
return undefined;
|
|
57
89
|
}
|
|
58
90
|
|
|
59
|
-
let commands: string[];
|
|
60
91
|
try {
|
|
61
|
-
|
|
92
|
+
return parseAutostartConfig(text);
|
|
62
93
|
} catch (err) {
|
|
63
94
|
const message = err instanceof AutostartParseError ? err.message : String(err);
|
|
64
95
|
ctx.ui.notify(
|
|
65
|
-
`autostart: skipped — ${message} (${
|
|
96
|
+
`autostart: skipped ${source.label} — ${message} (${source.path})`,
|
|
66
97
|
"error",
|
|
67
98
|
);
|
|
68
|
-
return;
|
|
99
|
+
return undefined;
|
|
69
100
|
}
|
|
70
|
-
|
|
101
|
+
}
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
pi.on("session_start", async (event, ctx) => {
|
|
104
|
+
if (!RUNNABLE_REASONS.has(event.reason)) return;
|
|
105
|
+
|
|
106
|
+
const sources: Source[] = [
|
|
107
|
+
{
|
|
108
|
+
path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
|
|
109
|
+
label: "global config",
|
|
110
|
+
requiresTrust: false,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
path: join(ctx.cwd, AUTOSTART_CONFIG_PATH),
|
|
114
|
+
label: "project config",
|
|
115
|
+
requiresTrust: true,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
79
118
|
|
|
80
119
|
// Only extension-registered commands are dispatchable; skill and
|
|
81
120
|
// prompt-template entries expand rather than execute.
|
|
@@ -87,23 +126,36 @@ export function registerAutostart(pi: ExtensionAPI): void {
|
|
|
87
126
|
),
|
|
88
127
|
);
|
|
89
128
|
|
|
90
|
-
for (const
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
129
|
+
for (const source of sources) {
|
|
130
|
+
const commands = await readCommands(ctx, source);
|
|
131
|
+
if (commands === undefined || commands.length === 0) continue;
|
|
132
|
+
|
|
133
|
+
if (source.requiresTrust && !ctx.isProjectTrusted()) {
|
|
93
134
|
ctx.ui.notify(
|
|
94
|
-
`autostart: skipped
|
|
135
|
+
`autostart: skipped ${source.label} — project not trusted (${source.path})`,
|
|
95
136
|
"warning",
|
|
96
137
|
);
|
|
97
138
|
continue;
|
|
98
139
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
140
|
+
|
|
141
|
+
for (const command of commands) {
|
|
142
|
+
const name = commandNameOf(command);
|
|
143
|
+
if (!name || !dispatchable.has(name)) {
|
|
144
|
+
ctx.ui.notify(
|
|
145
|
+
`autostart: skipped "${command}" — not a registered extension command`,
|
|
146
|
+
"warning",
|
|
147
|
+
);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// One failing command must not cancel the rest of the list.
|
|
151
|
+
try {
|
|
152
|
+
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
ctx.ui.notify(
|
|
155
|
+
`autostart: "${command}" failed — ${messageOf(err)}`,
|
|
156
|
+
"error",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
107
159
|
}
|
|
108
160
|
}
|
|
109
161
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed safety tables: credential paths are denied in every mode, destructive
|
|
3
|
+
* commands always ask.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { dangerReason, secretReason } from "./constants.js";
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
|
|
12
|
+
describe("secrets table", () => {
|
|
13
|
+
it("covers the credential directories exactly", () => {
|
|
14
|
+
expect(secretReason(join(HOME, ".ssh", "id_rsa"))).toBe("credentials");
|
|
15
|
+
expect(secretReason(join(HOME, ".ssh"))).toBe("credentials");
|
|
16
|
+
expect(secretReason(join(HOME, ".gnupg", "private-keys-v1.d"))).toBe("credentials");
|
|
17
|
+
expect(secretReason(join(HOME, ".aws", "credentials"))).toBe("credentials");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not over-match a sibling prefix", () => {
|
|
21
|
+
expect(secretReason(join(HOME, ".ssh-easy", "x"))).toBeUndefined();
|
|
22
|
+
expect(secretReason(join(HOME, ".ssh_evil", "x"))).toBeUndefined();
|
|
23
|
+
expect(secretReason(join(HOME, ".awsconfig", "x"))).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("covers pi auth files but nothing else under the agent dir", () => {
|
|
27
|
+
expect(secretReason(join(HOME, ".pi", "agent", "auth.json"))).toBe("pi credentials");
|
|
28
|
+
expect(secretReason(join(HOME, ".pi", "agent", "auth-foo.json"))).toBe("pi credentials");
|
|
29
|
+
expect(secretReason(join(HOME, ".pi", "agent", "settings.json"))).toBeUndefined();
|
|
30
|
+
expect(secretReason(join(HOME, ".pi", "agent", "xauth", "auth.json"))).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("covers .env files but not the templates", () => {
|
|
34
|
+
expect(secretReason("/proj/.env")).toBe("environment file");
|
|
35
|
+
expect(secretReason("/proj/.env.local")).toBe("environment file");
|
|
36
|
+
expect(secretReason("/proj/.env.example")).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("danger table", () => {
|
|
41
|
+
it("catches the destructive core", () => {
|
|
42
|
+
expect(dangerReason("rm", ["-rf", "/"], ["/"], "/proj")).toContain("recursive delete");
|
|
43
|
+
expect(dangerReason("rm", ["-rf", HOME], [HOME], "/proj")).toContain("recursive delete");
|
|
44
|
+
expect(dangerReason("rm", ["-rf", "/proj"], ["/proj"], "/proj")).toContain("recursive delete");
|
|
45
|
+
expect(dangerReason("git", ["push", "--force"], [], "/proj")).toBe("forced git push");
|
|
46
|
+
expect(dangerReason("git", ["push", "-f"], [], "/proj")).toBe("forced git push");
|
|
47
|
+
expect(dangerReason("git", ["reset", "--hard"], [], "/proj")).toBe("git reset --hard");
|
|
48
|
+
expect(dangerReason("git", ["clean", "-fd"], [], "/proj")).toBe("git clean -f");
|
|
49
|
+
expect(dangerReason("npm", ["publish"], [], "/proj")).toBe("package publish");
|
|
50
|
+
expect(dangerReason("dd", ["of=/dev/sda"], ["/dev/sda"], "/proj")).toBe("raw device write");
|
|
51
|
+
expect(dangerReason("sudo", ["rm", "x"], ["/proj/x"], "/proj")).toBe("privileged command");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not flag ordinary commands", () => {
|
|
55
|
+
expect(dangerReason("rm", ["-rf", "/proj/build"], ["/proj/build"], "/proj")).toBeUndefined();
|
|
56
|
+
expect(dangerReason("git", ["push"], [], "/proj")).toBeUndefined();
|
|
57
|
+
expect(dangerReason("git", ["log"], [], "/proj")).toBeUndefined();
|
|
58
|
+
expect(dangerReason("npm", ["view", "react"], [], "/proj")).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — modes, labels, and the fixed safety tables.
|
|
3
|
+
*
|
|
4
|
+
* The four modes are deliberately small: project boundary (in/out) crossed
|
|
5
|
+
* with operation kind (read/write, exec folded into write). There is no rule
|
|
6
|
+
* matrix — session grants cover the exceptions.
|
|
7
|
+
*
|
|
8
|
+
* Two tables sit outside the four modes on purpose:
|
|
9
|
+
* - {@link SECRET_ROOTS} / {@link isSecretFile}: reads and writes of
|
|
10
|
+
* credential material are denied in every mode. Without this, the default
|
|
11
|
+
* `outside read = allow` would hand the agent `~/.ssh/id_rsa`.
|
|
12
|
+
* - {@link DANGER_REASON}: destructive commands are always asked about, even
|
|
13
|
+
* when their scope says allow (`rm -rf .` inside the project is still a
|
|
14
|
+
* guarded operation). Scope alone cannot express "destructive".
|
|
15
|
+
*/
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, join, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Three-way mode, used for outside-project operations. */
|
|
20
|
+
export type TriMode = "ask" | "allow" | "deny";
|
|
21
|
+
|
|
22
|
+
/** Two-way mode, used for inside-project operations. */
|
|
23
|
+
export type PairMode = "ask" | "allow";
|
|
24
|
+
|
|
25
|
+
/** What one tool call does to the filesystem. */
|
|
26
|
+
export type Effect = "read" | "write";
|
|
27
|
+
|
|
28
|
+
/** The four permission modes, all at once. */
|
|
29
|
+
export interface PermissionModes {
|
|
30
|
+
inRead: PairMode;
|
|
31
|
+
inWrite: PairMode;
|
|
32
|
+
outRead: TriMode;
|
|
33
|
+
outWrite: TriMode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Defaults: project-internal work is uninterrupted; outside reads are allowed
|
|
38
|
+
* (the secrets table is what protects credentials); outside writes ask.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_MODES: PermissionModes = {
|
|
41
|
+
inRead: "allow",
|
|
42
|
+
inWrite: "allow",
|
|
43
|
+
outRead: "allow",
|
|
44
|
+
outWrite: "ask",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Footer status key (extension-scoped; `/ask` uses its own). */
|
|
48
|
+
export const PERMISSION_STATUS_KEY = "perm-mode";
|
|
49
|
+
|
|
50
|
+
/** Values offered by the mode panel, in cycle order. */
|
|
51
|
+
export const IN_VALUES: readonly PairMode[] = ["allow", "ask"];
|
|
52
|
+
export const OUT_VALUES: readonly TriMode[] = ["allow", "ask", "deny"];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Credential locations denied in every mode. `~` is expanded at match time so
|
|
56
|
+
* the table survives a moved home directory.
|
|
57
|
+
*/
|
|
58
|
+
export const SECRET_ROOTS: readonly string[] = [
|
|
59
|
+
"~/.ssh",
|
|
60
|
+
"~/.gnupg",
|
|
61
|
+
"~/.aws",
|
|
62
|
+
"~/.pi/agent/auth.json",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** `.env` family: secrets by convention, except the sample templates. */
|
|
66
|
+
const ENV_TEMPLATES = new Set([".env.example", ".env.sample", ".env.template"]);
|
|
67
|
+
|
|
68
|
+
/** pi's own credential files other than the exact `auth.json` entry. */
|
|
69
|
+
function isPiAuthFile(pathValue: string): boolean {
|
|
70
|
+
const authPrefix = join(homedir(), ".pi", "agent", "auth");
|
|
71
|
+
return pathValue.startsWith(authPrefix) && basename(pathValue).endsWith(".json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Containment: `pathValue` is `root` itself or under it (with sep boundaries). */
|
|
75
|
+
function pathWithin(root: string, pathValue: string): boolean {
|
|
76
|
+
const a = process.platform === "win32" ? root.toLowerCase() : root;
|
|
77
|
+
const b = process.platform === "win32" ? pathValue.toLowerCase() : pathValue;
|
|
78
|
+
return b === a || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `true` when `pathValue` is `.env`-shaped and not a template. */
|
|
82
|
+
function isSecretEnvFile(pathValue: string): boolean {
|
|
83
|
+
const name = basename(pathValue);
|
|
84
|
+
if (ENV_TEMPLATES.has(name)) return false;
|
|
85
|
+
return name === ".env" || name.startsWith(".env.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Absolute secret prefixes, `~` expanded. */
|
|
89
|
+
export function secretRoots(): string[] {
|
|
90
|
+
return SECRET_ROOTS.map((root) =>
|
|
91
|
+
root.startsWith("~") ? join(homedir(), root.slice(1)) : root,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Why `pathValue` is secret, or `undefined` when it is not. */
|
|
96
|
+
export function secretReason(pathValue: string): string | undefined {
|
|
97
|
+
if (isPiAuthFile(pathValue)) return "pi credentials";
|
|
98
|
+
if (isSecretEnvFile(pathValue)) return "environment file";
|
|
99
|
+
for (const root of secretRoots()) {
|
|
100
|
+
if (pathWithin(root, pathValue)) return "credentials";
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Commands always confirmed regardless of mode. Returns the human-facing
|
|
107
|
+
* reason, or `undefined` for an ordinary command.
|
|
108
|
+
*
|
|
109
|
+
* `targets` are the unit's already-resolved absolute paths.
|
|
110
|
+
*/
|
|
111
|
+
export function dangerReason(
|
|
112
|
+
command: string,
|
|
113
|
+
args: readonly string[],
|
|
114
|
+
targets: readonly string[],
|
|
115
|
+
base: string | null,
|
|
116
|
+
): string | undefined {
|
|
117
|
+
const home = homedir();
|
|
118
|
+
const inDangerousRoot = (pathValue: string): boolean =>
|
|
119
|
+
pathValue === "/" ||
|
|
120
|
+
pathValue === home ||
|
|
121
|
+
(base !== null && pathValue === base);
|
|
122
|
+
|
|
123
|
+
if (command === "sudo" || command === "doas") return "privileged command";
|
|
124
|
+
if (command === "rm" && targets.some(inDangerousRoot)) {
|
|
125
|
+
return "recursive delete of /, the home directory, or the working directory";
|
|
126
|
+
}
|
|
127
|
+
if (command === "git") {
|
|
128
|
+
const [verb, ...rest] = args;
|
|
129
|
+
if (verb === "reset" && rest.includes("--hard")) return "git reset --hard";
|
|
130
|
+
if (verb === "clean" && rest.some((a) => /^-[a-zA-Z]*f/.test(a))) {
|
|
131
|
+
return "git clean -f";
|
|
132
|
+
}
|
|
133
|
+
if (verb === "push" && rest.some((a) => a === "-f" || a === "--force")) {
|
|
134
|
+
return "forced git push";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
["npm", "pnpm", "yarn", "bun"].includes(command) &&
|
|
139
|
+
args[0] === "publish"
|
|
140
|
+
) {
|
|
141
|
+
return "package publish";
|
|
142
|
+
}
|
|
143
|
+
if (command === "dd" && targets.some((t) => t.startsWith("/dev/"))) {
|
|
144
|
+
return "raw device write";
|
|
145
|
+
}
|
|
146
|
+
if (command.startsWith("mkfs")) return "filesystem creation";
|
|
147
|
+
if (["shutdown", "reboot", "poweroff", "halt"].includes(command)) {
|
|
148
|
+
return "system power command";
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
command === "chmod" &&
|
|
152
|
+
args.some((a) => /^-[a-zA-Z]*R/.test(a)) &&
|
|
153
|
+
args.includes("777")
|
|
154
|
+
) {
|
|
155
|
+
return "recursive world-writable chmod";
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|