@piagent/platform 1.0.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.
Files changed (159) hide show
  1. package/.gitattributes +7 -0
  2. package/AGENTS.md +31 -0
  3. package/CHANGELOG.md +296 -0
  4. package/CODE_OF_CONDUCT.md +36 -0
  5. package/CONTRIBUTING.md +66 -0
  6. package/LICENSE +21 -0
  7. package/README.md +588 -0
  8. package/SECURITY.md +38 -0
  9. package/adapters/backend-api/profile.json +88 -0
  10. package/adapters/be-readonly-fe/profile.json +120 -0
  11. package/adapters/data/profile.json +88 -0
  12. package/adapters/devops/profile.json +92 -0
  13. package/adapters/docs/profile.json +74 -0
  14. package/adapters/fullstack/profile.json +94 -0
  15. package/adapters/generic/profile.json +95 -0
  16. package/adapters/mobile/profile.json +87 -0
  17. package/adapters/node-typescript/profile.json +76 -0
  18. package/adapters/python/profile.json +75 -0
  19. package/adapters/web-frontend/profile.json +101 -0
  20. package/catalog/capabilities.json +206 -0
  21. package/docs/architecture.md +64 -0
  22. package/docs/auto-delegation-policy.md +192 -0
  23. package/docs/capability-packs.md +234 -0
  24. package/docs/command-reference-vietnamese.md +484 -0
  25. package/docs/context-window-policy.md +85 -0
  26. package/docs/distribution-standard.md +229 -0
  27. package/docs/herdr-workflow.md +58 -0
  28. package/docs/mcp-and-tools.md +316 -0
  29. package/docs/memory-policy.md +164 -0
  30. package/docs/model-options.md +165 -0
  31. package/docs/oauth-providers.md +53 -0
  32. package/docs/operator-manual-vietnamese.md +1080 -0
  33. package/docs/package-architecture-notes.md +101 -0
  34. package/docs/project-adapters.md +174 -0
  35. package/docs/project-onboarding.md +135 -0
  36. package/docs/publishing-for-teams.md +64 -0
  37. package/docs/quality-benchmark.md +74 -0
  38. package/docs/quickstart-vietnamese.md +350 -0
  39. package/docs/release-install-policy.md +223 -0
  40. package/docs/runtime-harness-standard.md +118 -0
  41. package/docs/runtime-policy-design.md +132 -0
  42. package/docs/runtime-quality-baseline.md +257 -0
  43. package/docs/security-redaction-benchmark.md +56 -0
  44. package/docs/security-threat-model.md +66 -0
  45. package/docs/subagent-orchestration-capabilities.md +104 -0
  46. package/docs/subagents-and-multiagent.md +346 -0
  47. package/docs/task-implementation-contract.md +200 -0
  48. package/docs/team-onboarding.md +333 -0
  49. package/docs/usage-observability.md +130 -0
  50. package/docs/vercel-docs-site.md +115 -0
  51. package/docs/workflow-recipes.md +125 -0
  52. package/docs-site/assets/piagent-logo.svg +35 -0
  53. package/docs-site/favicon.svg +18 -0
  54. package/docs-site/index.html +1613 -0
  55. package/docs-site/vercel.json +24 -0
  56. package/evals/fixtures/action-proposal.invalid.json +34 -0
  57. package/evals/fixtures/action-proposal.valid.json +34 -0
  58. package/evals/fixtures/capability-pack.invalid.json +123 -0
  59. package/evals/fixtures/capability-pack.valid.json +123 -0
  60. package/evals/fixtures/capability-recipe.invalid.json +81 -0
  61. package/evals/fixtures/capability-recipe.valid.json +82 -0
  62. package/evals/fixtures/context-index.invalid.json +25 -0
  63. package/evals/fixtures/context-index.valid.json +25 -0
  64. package/evals/fixtures/eval-scenario.invalid.json +40 -0
  65. package/evals/fixtures/eval-scenario.valid.json +45 -0
  66. package/evals/fixtures/project-profile.invalid.json +88 -0
  67. package/evals/fixtures/project-profile.valid.json +95 -0
  68. package/evals/fixtures/task-contract.invalid.json +73 -0
  69. package/evals/fixtures/task-contract.valid.json +74 -0
  70. package/evals/golden/enforcement-decisions.json +329 -0
  71. package/evals/scenarios/capability-resolution.json +45 -0
  72. package/package.json +132 -0
  73. package/packages/piagent-core/README.md +147 -0
  74. package/packages/piagent-core/capabilities/capability-core.js +1023 -0
  75. package/packages/piagent-core/capabilities/capability-sources.js +265 -0
  76. package/packages/piagent-core/extensions/guard-io.js +45 -0
  77. package/packages/piagent-core/extensions/guard-shell-analysis.ts +558 -0
  78. package/packages/piagent-core/extensions/guard-types.ts +406 -0
  79. package/packages/piagent-core/extensions/piagent-guard.ts +5760 -0
  80. package/packages/piagent-core/extensions/policy-core.js +1070 -0
  81. package/packages/piagent-core/extensions/redaction-core.js +4 -0
  82. package/packages/piagent-core/extensions/runtime-evidence.js +215 -0
  83. package/packages/piagent-core/package.json +46 -0
  84. package/packages/piagent-core/policies/base-policy.json +249 -0
  85. package/packages/piagent-core/prompts/be-to-fe.md +82 -0
  86. package/packages/piagent-core/prompts/commit.md +27 -0
  87. package/packages/piagent-core/prompts/discuss.md +35 -0
  88. package/packages/piagent-core/prompts/memory-policy.md +52 -0
  89. package/packages/piagent-core/prompts/model-options.md +68 -0
  90. package/packages/piagent-core/prompts/onboard-project.md +147 -0
  91. package/packages/piagent-core/prompts/piagent-commands.md +68 -0
  92. package/packages/piagent-core/prompts/plan.md +35 -0
  93. package/packages/piagent-core/prompts/platform-improve.md +69 -0
  94. package/packages/piagent-core/prompts/pr.md +26 -0
  95. package/packages/piagent-core/prompts/review.md +38 -0
  96. package/packages/piagent-core/prompts/scout.md +50 -0
  97. package/packages/piagent-core/prompts/task.md +69 -0
  98. package/packages/piagent-core/security/sensitive-data.js +187 -0
  99. package/packages/piagent-core/skills/piagent-ops/SKILL.md +44 -0
  100. package/packages/piagent-core/skills/piagent-source-cache/SKILL.md +59 -0
  101. package/packages/piagent-core/skills/piagent-source-cache/checkout-source-repo.sh +144 -0
  102. package/packages/piagent-core/subagents/piagent-oracle.md +29 -0
  103. package/packages/piagent-core/subagents/piagent-planner.md +46 -0
  104. package/packages/piagent-core/subagents/piagent-reviewer.md +48 -0
  105. package/packages/piagent-core/subagents/piagent-scout.md +37 -0
  106. package/packages/piagent-core/subagents/piagent-worker.md +44 -0
  107. package/packs/engineering-base/pack.json +123 -0
  108. package/packs/engineering-base/recipes/bounded-change.json +82 -0
  109. package/packs/web-delivery/pack.json +72 -0
  110. package/packs/web-delivery/recipes/verified-web-change.json +57 -0
  111. package/schemas/action-proposal.schema.json +90 -0
  112. package/schemas/capability-pack.schema.json +190 -0
  113. package/schemas/capability-recipe.schema.json +72 -0
  114. package/schemas/context-index.schema.json +258 -0
  115. package/schemas/eval-scenario.schema.json +67 -0
  116. package/schemas/project-profile.schema.json +555 -0
  117. package/schemas/task-contract.schema.json +314 -0
  118. package/scripts/audit-runtime-host.sh +78 -0
  119. package/scripts/bootstrap-local.sh +25 -0
  120. package/scripts/capability-catalog.mjs +290 -0
  121. package/scripts/check-runtime-advisories.mjs +114 -0
  122. package/scripts/configure-mcp.sh +301 -0
  123. package/scripts/configure-model-scope.sh +143 -0
  124. package/scripts/configure-subagents.sh +241 -0
  125. package/scripts/import-agent-instructions.mjs +199 -0
  126. package/scripts/init-project.sh +369 -0
  127. package/scripts/install-global.sh +532 -0
  128. package/scripts/link-project.sh +25 -0
  129. package/scripts/migrate-project-state.mjs +176 -0
  130. package/scripts/pi-auto.sh +79 -0
  131. package/scripts/pi-model-catalog.sh +154 -0
  132. package/scripts/pi-session-stats.sh +135 -0
  133. package/scripts/piagent-cli.mjs +53 -0
  134. package/scripts/profile-doctor.sh +238 -0
  135. package/scripts/quality-benchmark.sh +185 -0
  136. package/scripts/redaction-benchmark.mjs +227 -0
  137. package/scripts/runtime-policy-smoke.sh +48 -0
  138. package/scripts/setup.sh +425 -0
  139. package/scripts/team-doctor.sh +461 -0
  140. package/scripts/verify-local.sh +540 -0
  141. package/scripts/verify-release-identity.mjs +85 -0
  142. package/scripts/verify-vercel-link.mjs +64 -0
  143. package/templates/global/mcp.json +9 -0
  144. package/templates/global/settings.json +37 -0
  145. package/templates/project/.mcp.json +9 -0
  146. package/templates/project/.pi/context-index.json +25 -0
  147. package/templates/project/.pi/gitignore.template +22 -0
  148. package/templates/project/.pi/mcp.json +9 -0
  149. package/templates/project/.pi/memory/MEMORY.md +11 -0
  150. package/templates/project/.pi/memory/memory_summary.md +9 -0
  151. package/templates/project/.pi/piagent-profile.json +107 -0
  152. package/templates/project/.pi/project-context.md +30 -0
  153. package/templates/project/.pi/settings.json +17 -0
  154. package/templates/project/.pi/task-contract.template.json +74 -0
  155. package/templates/project/.pi/tech-context/README.md +7 -0
  156. package/templates/project/.pi/tech-stack.json +10 -0
  157. package/templates/project/AGENTS.md +34 -0
  158. package/templates/project/REVIEW_GUIDELINES.md +23 -0
  159. package/types/pi-runtime-shims.d.ts +13 -0
@@ -0,0 +1,558 @@
1
+ import path from "node:path";
2
+
3
+ import type { ExternalActionPolicyConfig } from "./guard-types.ts";
4
+
5
+ // Classification of shell commands and external actions: what a command is
6
+ // about to reach, and whether that needs a human. This is the part of the guard
7
+ // that reasons about command text rather than about the filesystem, and it is
8
+ // pure — every function here takes what it needs and returns a decision, so a
9
+ // case can be argued about without a session, a project, or a policy file.
10
+ //
11
+ // The policy is passed in already resolved. These functions deliberately do not
12
+ // read a BasePolicy: taking the narrow config keeps the rules here independent
13
+ // of where the policy came from.
14
+
15
+ const MAX_SHELL_ARG_COUNT = 256;
16
+ const MAX_SHELL_ARG_CHARS = 16_384;
17
+ const MAX_SHELL_COMMAND_CHARS = 131_072;
18
+
19
+ export function normalizeActionToken(value: string): string {
20
+ return value
21
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, "-")
24
+ .replace(/^-+|-+$/g, "");
25
+ }
26
+
27
+ export function actionTextMatchesAny(text: string, tokens: string[]): boolean {
28
+ const normalized = normalizeActionToken(text);
29
+ if (!normalized) return false;
30
+ return tokens.some((token) => {
31
+ const normalizedToken = normalizeActionToken(token);
32
+ return normalizedToken.length > 0
33
+ && new RegExp(`(?:^|-)${normalizedToken}(?:-|$)`).test(normalized);
34
+ });
35
+ }
36
+
37
+ export type ActionClassification = {
38
+ decision: "safe-read" | "confirm";
39
+ kind: "safe" | "write" | "ambiguous";
40
+ action: string;
41
+ };
42
+
43
+ export function actionTokens(value: string): string[] {
44
+ return normalizeActionToken(value).split("-").filter(Boolean);
45
+ }
46
+
47
+ export function classifyActionTokenSequence(tokens: string[], config: Required<ExternalActionPolicyConfig>): ActionClassification {
48
+ const writeTokens = new Set(config.writeVerbs.map(normalizeActionToken));
49
+ const safeTokens = new Set(config.safeVerbs.map(normalizeActionToken));
50
+ const matches = tokens
51
+ .map((token, index) => ({ token, index, write: writeTokens.has(token), safe: safeTokens.has(token) }))
52
+ .filter((item) => item.write || item.safe);
53
+ const first = matches[0];
54
+ if (!first) return { decision: "confirm", kind: "ambiguous", action: "unknown" };
55
+ if (first.write) return { decision: "confirm", kind: "write", action: first.token };
56
+
57
+ // A safe prefix is not enough for compound/ambiguous names such as
58
+ // get_update_file. Keep the small set of established read resources whose
59
+ // noun also happens to be a configured write verb (for example get_release).
60
+ const safeReadResourceCollisions = new Set(["release", "run"]);
61
+ const laterWrite = matches.find((item) => item.index > first.index
62
+ && item.write
63
+ && !safeReadResourceCollisions.has(item.token));
64
+ if (laterWrite) return { decision: "confirm", kind: "write", action: laterWrite.token };
65
+ return { decision: "safe-read", kind: "safe", action: first.token };
66
+ }
67
+
68
+ export function classifyExplicitActionValues(values: string[], config: Required<ExternalActionPolicyConfig>): ActionClassification | undefined {
69
+ if (values.length === 0) return undefined;
70
+ const classifications = values.map((value) => classifyActionTokenSequence(actionTokens(value), config));
71
+ return classifications.find((item) => item.kind === "write")
72
+ ?? classifications.find((item) => item.kind === "ambiguous")
73
+ ?? classifications[0];
74
+ }
75
+
76
+ export function classifyToolNameAction(toolName: string, provider: string, config: Required<ExternalActionPolicyConfig>): ActionClassification {
77
+ let tokens = actionTokens(toolName);
78
+ if (tokens[0] === "mcp") tokens = tokens.slice(1);
79
+ const providerTokens = actionTokens(provider);
80
+ const providerIndex = tokens.findIndex((token, index) => providerTokens.every((providerToken, offset) => tokens[index + offset] === providerToken));
81
+ if (providerIndex >= 0) tokens = tokens.slice(providerIndex + providerTokens.length);
82
+
83
+ return classifyActionTokenSequence(tokens, config);
84
+ }
85
+
86
+ const GH_COMMAND_GROUPS = new Set([
87
+ "alias", "api", "auth", "cache", "codespace", "config", "extension", "gist", "gpg-key",
88
+ "issue", "label", "org", "pr", "project", "release", "repo", "ruleset", "run", "secret",
89
+ "ssh-key", "variable", "workflow"
90
+ ]);
91
+ const GH_SAFE_ACTIONS = new Set([
92
+ "browse", "checks", "completion", "diff", "fetch", "find", "get", "help", "inspect", "list",
93
+ "read", "search", "show", "status", "version", "view"
94
+ ]);
95
+
96
+ export function executableBasename(value: string): string {
97
+ return path.posix.basename(value.replace(/\\/g, "/")).toLowerCase();
98
+ }
99
+
100
+ export function externalCommandName(value: string, names: Set<string>, aliases: Map<string, string>): string | undefined {
101
+ const direct = executableBasename(value);
102
+ if (names.has(direct)) return direct;
103
+ const variable = value.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/);
104
+ const alias = variable ? aliases.get(variable[1] ?? variable[2]) : undefined;
105
+ return alias && names.has(alias) ? alias : undefined;
106
+ }
107
+
108
+ export function externalExecutableIndex(
109
+ words: string[],
110
+ names: Set<string>,
111
+ aliases: Map<string, string>
112
+ ): number | undefined {
113
+ let index = 0;
114
+ while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) index += 1;
115
+
116
+ const controlPrefixes = new Set(["!", "{", "(", "then", "do", "elif", "else"]);
117
+ const nonExecutingCommands = new Set([
118
+ "cat", "echo", "egrep", "fgrep", "grep", "printf", "rg", "ripgrep", "test", "[", "true", "false"
119
+ ]);
120
+ while (index < words.length) {
121
+ const command = executableBasename(words[index]);
122
+ if (externalCommandName(words[index], names, aliases)) return index;
123
+ if (controlPrefixes.has(command)) {
124
+ index += 1;
125
+ continue;
126
+ }
127
+ const ripgrepPreprocessor = ["rg", "ripgrep"].includes(command) && hasOption(words.slice(index + 1), ["--pre"]);
128
+ if (nonExecutingCommands.has(command) && !ripgrepPreprocessor) return undefined;
129
+ if (["command", "exec", "nohup", "time"].includes(command)) {
130
+ index += 1;
131
+ while (index < words.length && words[index].startsWith("-")) {
132
+ const option = words[index];
133
+ index += ["-a", "-f", "-o", "--format", "--output"].includes(option) ? 2 : 1;
134
+ }
135
+ continue;
136
+ }
137
+ if (command === "env") {
138
+ index += 1;
139
+ while (index < words.length) {
140
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
141
+ index += 1;
142
+ continue;
143
+ }
144
+ if (["-u", "--unset", "-C", "--chdir"].includes(words[index])) {
145
+ index += 2;
146
+ continue;
147
+ }
148
+ if (words[index].startsWith("-")) {
149
+ index += 1;
150
+ continue;
151
+ }
152
+ break;
153
+ }
154
+ continue;
155
+ }
156
+ if (command === "sudo") {
157
+ index += 1;
158
+ while (index < words.length) {
159
+ const option = words[index];
160
+ if (["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-D", "--chdir"].includes(option)) {
161
+ index += 2;
162
+ continue;
163
+ }
164
+ if (option.startsWith("-")) {
165
+ index += 1;
166
+ continue;
167
+ }
168
+ break;
169
+ }
170
+ continue;
171
+ }
172
+ if (command === "nice") {
173
+ index += 1;
174
+ if (["-n", "--adjustment"].includes(words[index])) index += 2;
175
+ else if (words[index]?.startsWith("--adjustment=") || /^-\d+$/.test(words[index] ?? "")) index += 1;
176
+ continue;
177
+ }
178
+ if (command === "find") {
179
+ const execIndex = words.findIndex((word, nestedIndex) => nestedIndex > index && ["-exec", "-execdir"].includes(word));
180
+ if (execIndex < 0) return undefined;
181
+ const nested = words.findIndex((word, nestedIndex) => nestedIndex > execIndex && externalCommandName(word, names, aliases));
182
+ return nested >= 0 ? nested : undefined;
183
+ }
184
+ if (["xargs", "npx", "bunx"].includes(command) || (command === "pnpm" && words[index + 1] === "dlx")) {
185
+ const nested = words.findIndex((word, nestedIndex) => nestedIndex > index && externalCommandName(word, names, aliases));
186
+ return nested >= 0 ? nested : undefined;
187
+ }
188
+ // Unknown wrappers and shell constructs fail closed when they carry a
189
+ // literal external executable later in the same semantic segment.
190
+ const nested = words.findIndex((word, nestedIndex) => nestedIndex > index && externalCommandName(word, names, aliases));
191
+ return nested >= 0 ? nested : undefined;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ export function containsDynamicShellExpansion(value: string): boolean {
197
+ return /(?:\$\(|`|\$\{|\$[A-Za-z0-9_@*#?$!-])/.test(value);
198
+ }
199
+
200
+ export function assignmentEndIndex(words: string[], startIndex: number): number {
201
+ let commandSubstitutionDepth = 0;
202
+ let parameterExpansionDepth = 0;
203
+ let insideBackticks = false;
204
+ for (let index = startIndex; index < words.length; index += 1) {
205
+ const equalsIndex = index === startIndex ? words[index].indexOf("=") : -1;
206
+ const value = equalsIndex >= 0 ? words[index].slice(equalsIndex + 1) : words[index];
207
+ for (let charIndex = 0; charIndex < value.length; charIndex += 1) {
208
+ const char = value[charIndex];
209
+ const next = value[charIndex + 1];
210
+ if (char === "`") {
211
+ insideBackticks = !insideBackticks;
212
+ continue;
213
+ }
214
+ if (insideBackticks) continue;
215
+ if (char === "$" && next === "(") {
216
+ commandSubstitutionDepth += 1;
217
+ charIndex += 1;
218
+ continue;
219
+ }
220
+ if (commandSubstitutionDepth > 0 && char === "(") commandSubstitutionDepth += 1;
221
+ else if (commandSubstitutionDepth > 0 && char === ")") commandSubstitutionDepth -= 1;
222
+ if (char === "$" && next === "{") {
223
+ parameterExpansionDepth += 1;
224
+ charIndex += 1;
225
+ continue;
226
+ }
227
+ if (parameterExpansionDepth > 0 && char === "{") parameterExpansionDepth += 1;
228
+ else if (parameterExpansionDepth > 0 && char === "}") parameterExpansionDepth -= 1;
229
+ }
230
+ if (commandSubstitutionDepth === 0 && parameterExpansionDepth === 0 && !insideBackticks) return index + 1;
231
+ }
232
+ return words.length;
233
+ }
234
+
235
+ export function dynamicExecutableIndex(
236
+ words: string[],
237
+ names: Set<string>,
238
+ aliases: Map<string, string>
239
+ ): number | undefined {
240
+ let index = 0;
241
+ while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
242
+ // shellWords intentionally exposes nested command text as separate words;
243
+ // consume the balanced assignment value before looking for an executable.
244
+ index = assignmentEndIndex(words, index);
245
+ }
246
+
247
+ const controlPrefixes = new Set(["!", "{", "(", "then", "do", "elif", "else"]);
248
+ const nonExecutingCommands = new Set([
249
+ "cat", "echo", "egrep", "fgrep", "grep", "printf", "rg", "ripgrep", "test", "[", "true", "false"
250
+ ]);
251
+ while (index < words.length) {
252
+ const word = words[index];
253
+ const command = executableBasename(word);
254
+ if (externalCommandName(word, names, aliases)) return undefined;
255
+ if (containsDynamicShellExpansion(word)) return index;
256
+ if (controlPrefixes.has(command)) {
257
+ index += 1;
258
+ continue;
259
+ }
260
+ const ripgrepPreprocessor = ["rg", "ripgrep"].includes(command) && hasOption(words.slice(index + 1), ["--pre"]);
261
+ if (nonExecutingCommands.has(command) && !ripgrepPreprocessor) return undefined;
262
+ if (["command", "exec", "nohup", "time"].includes(command)) {
263
+ index += 1;
264
+ while (index < words.length && words[index].startsWith("-")) {
265
+ const option = words[index];
266
+ index += ["-a", "-f", "-o", "--format", "--output"].includes(option) ? 2 : 1;
267
+ }
268
+ continue;
269
+ }
270
+ if (command === "env") {
271
+ index += 1;
272
+ while (index < words.length) {
273
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
274
+ index += 1;
275
+ continue;
276
+ }
277
+ if (["-u", "--unset", "-C", "--chdir"].includes(words[index])) {
278
+ index += 2;
279
+ continue;
280
+ }
281
+ if (words[index].startsWith("-")) {
282
+ index += 1;
283
+ continue;
284
+ }
285
+ break;
286
+ }
287
+ continue;
288
+ }
289
+ if (command === "sudo") {
290
+ index += 1;
291
+ while (index < words.length) {
292
+ const option = words[index];
293
+ if (["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-D", "--chdir"].includes(option)) {
294
+ index += 2;
295
+ continue;
296
+ }
297
+ if (option.startsWith("-")) {
298
+ index += 1;
299
+ continue;
300
+ }
301
+ break;
302
+ }
303
+ continue;
304
+ }
305
+ if (command === "nice") {
306
+ index += 1;
307
+ if (["-n", "--adjustment"].includes(words[index])) index += 2;
308
+ else if (words[index]?.startsWith("--adjustment=") || /^-\d+$/.test(words[index] ?? "")) index += 1;
309
+ continue;
310
+ }
311
+ if (command === "find") {
312
+ const execIndex = words.findIndex((candidate, nestedIndex) => nestedIndex > index && ["-exec", "-execdir"].includes(candidate));
313
+ if (execIndex < 0) return undefined;
314
+ const nested = dynamicExecutableIndex(words.slice(execIndex + 1), names, aliases);
315
+ return nested === undefined ? undefined : execIndex + 1 + nested;
316
+ }
317
+ if (["xargs", "npx", "bunx"].includes(command) || (command === "pnpm" && words[index + 1] === "dlx")) {
318
+ let nestedIndex = index + (command === "pnpm" ? 2 : 1);
319
+ const optionsWithValues = new Set([
320
+ "-a", "--arg-file", "-d", "--delimiter", "-E", "--eof", "-I", "--replace", "-L", "--max-lines",
321
+ "-n", "--max-args", "-P", "--max-procs", "-s", "--max-chars", "-p", "--package"
322
+ ]);
323
+ while (nestedIndex < words.length) {
324
+ const option = words[nestedIndex];
325
+ if (option === "--") {
326
+ nestedIndex += 1;
327
+ break;
328
+ }
329
+ if (optionsWithValues.has(option)) {
330
+ nestedIndex += 2;
331
+ continue;
332
+ }
333
+ if (option.startsWith("-")) {
334
+ nestedIndex += 1;
335
+ continue;
336
+ }
337
+ break;
338
+ }
339
+ const nested = dynamicExecutableIndex(words.slice(nestedIndex), names, aliases);
340
+ return nested === undefined ? undefined : nestedIndex + nested;
341
+ }
342
+ return undefined;
343
+ }
344
+ return undefined;
345
+ }
346
+
347
+ export function inspectOptionValues(words: string[], shortName: string, longName: string): {
348
+ found: boolean;
349
+ missing: boolean;
350
+ values: string[];
351
+ } {
352
+ const result = { found: false, missing: false, values: [] as string[] };
353
+ for (let index = 0; index < words.length; index += 1) {
354
+ const word = words[index];
355
+ if ((shortName && word === shortName) || word === longName) {
356
+ result.found = true;
357
+ const value = words[index + 1];
358
+ if (!value || value.startsWith("-")) result.missing = true;
359
+ else {
360
+ result.values.push(value);
361
+ index += 1;
362
+ }
363
+ continue;
364
+ }
365
+ if (word.startsWith(`${longName}=`)) {
366
+ result.found = true;
367
+ const value = word.slice(longName.length + 1);
368
+ if (value) result.values.push(value);
369
+ else result.missing = true;
370
+ continue;
371
+ }
372
+ if (shortName.length === 2 && word.startsWith(shortName) && word.length > shortName.length) {
373
+ result.found = true;
374
+ result.values.push(word.slice(shortName.length));
375
+ }
376
+ }
377
+ return result;
378
+ }
379
+
380
+ export function hasOption(words: string[], names: string[]): boolean {
381
+ return words.some((word) => names.some((name) => word === name || word.startsWith(`${name}=`)
382
+ || (name.length === 2 && word.startsWith(name) && word.length > name.length)));
383
+ }
384
+
385
+ export function ghApiRequiresConfirmation(words: string[]): boolean {
386
+ const methods = inspectOptionValues(words, "-X", "--method");
387
+ if (methods.missing || methods.values.some((method) => !["GET", "HEAD"].includes(method.toUpperCase()))) return true;
388
+ const carriesFields = hasOption(words, ["-f", "-F", "--field", "--raw-field", "--input"]);
389
+ return carriesFields && !(methods.values.length > 0 && methods.values.every((method) => method.toUpperCase() === "GET"));
390
+ }
391
+
392
+ export function ghRequiresConfirmation(words: string[]): boolean {
393
+ const args = words.slice(1);
394
+ if (hasOption(args, ["-h", "--help", "--version"])) return false;
395
+ const positionals: string[] = [];
396
+ const optionsWithValues = new Set(["-R", "--repo", "--hostname"]);
397
+ for (let index = 0; index < args.length; index += 1) {
398
+ const word = args[index];
399
+ if (optionsWithValues.has(word)) {
400
+ index += 1;
401
+ continue;
402
+ }
403
+ if (word.startsWith("-")) continue;
404
+ positionals.push(normalizeActionToken(word));
405
+ if (positionals.length >= 2) break;
406
+ }
407
+ const group = positionals[0] ?? "";
408
+ if (group === "api") return ghApiRequiresConfirmation(args.slice(1));
409
+ if (["search", "status", "browse", "completion", "help", "version"].includes(group)) return false;
410
+ const action = GH_COMMAND_GROUPS.has(group) ? positionals[1] : group;
411
+ return !action || !GH_SAFE_ACTIONS.has(action);
412
+ }
413
+
414
+ export function curlRequiresConfirmation(words: string[]): boolean {
415
+ const args = words.slice(1);
416
+ const methods = inspectOptionValues(args, "-X", "--request");
417
+ if (methods.missing || methods.values.some((method) => !["GET", "HEAD"].includes(method.toUpperCase()))) return true;
418
+ if (hasOption(args, ["-K", "--config", "-T", "--upload-file", "-F", "--form", "--form-string", "--json"])) return true;
419
+ const quoteCommands = inspectOptionValues(args, "-Q", "--quote");
420
+ if (quoteCommands.missing) return true;
421
+ const safeQuoteCommands = /^(?:[+-])?(?:CWD|FEAT|HELP|NOOP|PWD|STAT|SYST)\b/i;
422
+ if (quoteCommands.values.some((command) => !safeQuoteCommands.test(command.trim()))) return true;
423
+ const carriesData = hasOption(args, ["-d", "--data", "--data-ascii", "--data-binary", "--data-raw", "--data-urlencode"]);
424
+ const forceGet = hasOption(args, ["-G", "--get"]);
425
+ return carriesData && !forceGet;
426
+ }
427
+
428
+ export function wgetRequiresConfirmation(words: string[]): boolean {
429
+ const args = words.slice(1);
430
+ const methods = inspectOptionValues(args, "", "--method");
431
+ if (methods.missing || methods.values.some((method) => !["GET", "HEAD"].includes(method.toUpperCase()))) return true;
432
+ return hasOption(args, ["-e", "--execute", "--config", "--post-data", "--post-file", "--body-data", "--body-file", "--upload-file"]);
433
+ }
434
+
435
+ export function findShellExternalConfirmationReason(
436
+ segments: Array<{ command: string; words: string[] }>,
437
+ externalActionPolicy: Required<ExternalActionPolicyConfig>
438
+ ): string | undefined {
439
+ if (externalActionPolicy.defaultMode !== "enforce") return undefined;
440
+ const names = new Set(["gh", "curl", "wget"]);
441
+ const aliases = new Map<string, string>();
442
+ for (const segment of segments) {
443
+ for (const word of segment.words) {
444
+ const assignment = word.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/);
445
+ if (!assignment) continue;
446
+ const executable = executableBasename(assignment[2]);
447
+ if (names.has(executable)) aliases.set(assignment[1], executable);
448
+ }
449
+ }
450
+ for (const segment of segments) {
451
+ const dynamicIndex = dynamicExecutableIndex(segment.words, names, aliases);
452
+ if (dynamicIndex !== undefined) {
453
+ return `External command requires confirmation: dynamic executable in ${segment.command}`;
454
+ }
455
+ const index = externalExecutableIndex(segment.words, names, aliases);
456
+ if (index === undefined) continue;
457
+ const invocation = segment.words.slice(index);
458
+ const command = externalCommandName(invocation[0], names, aliases);
459
+ if (!command) continue;
460
+ const requiresConfirmation = command === "gh"
461
+ ? ghRequiresConfirmation(invocation)
462
+ : command === "curl"
463
+ ? curlRequiresConfirmation(invocation)
464
+ : wgetRequiresConfirmation(invocation);
465
+ if (requiresConfirmation) return `External command requires confirmation: ${command} in ${segment.command}`;
466
+ }
467
+ return undefined;
468
+ }
469
+
470
+ export function quoteShellArgument(value: string): string {
471
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
472
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
473
+ }
474
+
475
+ export function normalizeShellCommandForPolicy(command: string): string {
476
+ const collapsed = command.replace(/\\(?:\r\n|\n)/g, "");
477
+ let normalized = "";
478
+ let quote: "'" | '"' | undefined;
479
+ let escaped = false;
480
+ for (let index = 0; index < collapsed.length; index += 1) {
481
+ const char = collapsed[index];
482
+ if (escaped) {
483
+ normalized += char;
484
+ escaped = false;
485
+ continue;
486
+ }
487
+ if (char === "\\") {
488
+ normalized += char;
489
+ escaped = true;
490
+ continue;
491
+ }
492
+ if ((char === "'" || char === '"') && !quote) {
493
+ quote = char;
494
+ normalized += char;
495
+ continue;
496
+ }
497
+ if (quote === char) {
498
+ quote = undefined;
499
+ normalized += char;
500
+ continue;
501
+ }
502
+ const previous = collapsed[index - 1];
503
+ const beginsShellWord = index === 0 || /\s/.test(previous) || /[;&|(){}]/.test(previous);
504
+ if (!quote && char === "#" && beginsShellWord) {
505
+ while (index < collapsed.length && collapsed[index] !== "\n") index += 1;
506
+ if (collapsed[index] === "\n") normalized += "\n";
507
+ continue;
508
+ }
509
+ normalized += char;
510
+ }
511
+ return normalized;
512
+ }
513
+
514
+ export function extractShellCommandInput(input: Record<string, unknown>): { command?: string; reason?: string } {
515
+ const hasCommand = Object.hasOwn(input, "command");
516
+ const hasCmd = Object.hasOwn(input, "cmd");
517
+ const hasArgs = Object.hasOwn(input, "args");
518
+ if (hasCommand && typeof input.command !== "string") {
519
+ return { reason: "command must be a string" };
520
+ }
521
+ if (hasCmd && typeof input.cmd !== "string") {
522
+ return { reason: "cmd must be a string" };
523
+ }
524
+
525
+ const command = hasCommand ? input.command as string : undefined;
526
+ const cmd = hasCmd ? input.cmd as string : undefined;
527
+ if (command !== undefined && cmd !== undefined && command !== cmd) {
528
+ return { reason: "conflicting command and cmd values" };
529
+ }
530
+
531
+ let args: string[] = [];
532
+ if (hasArgs) {
533
+ if (!Array.isArray(input.args) || !input.args.every((arg) => typeof arg === "string")) {
534
+ return { reason: "args must be an array of strings" };
535
+ }
536
+ if (input.args.length > MAX_SHELL_ARG_COUNT) {
537
+ return { reason: `too many args: ${input.args.length} > ${MAX_SHELL_ARG_COUNT}` };
538
+ }
539
+ if (input.args.some((arg) => arg.length > MAX_SHELL_ARG_CHARS)) {
540
+ return { reason: `shell arg exceeds ${MAX_SHELL_ARG_CHARS} characters` };
541
+ }
542
+ args = input.args;
543
+ }
544
+
545
+ const baseCommand = command ?? cmd;
546
+ if (baseCommand !== undefined && (!baseCommand.trim() || baseCommand.length > MAX_SHELL_COMMAND_CHARS)) {
547
+ return { reason: `shell command must contain 1-${MAX_SHELL_COMMAND_CHARS} characters` };
548
+ }
549
+ if (baseCommand === undefined && args.length === 0) {
550
+ return { reason: "shell command input is missing or unsupported" };
551
+ }
552
+
553
+ const combined = [baseCommand?.trim(), ...args.map(quoteShellArgument)].filter((item): item is string => Boolean(item)).join(" ");
554
+ if (combined.length > MAX_SHELL_COMMAND_CHARS) {
555
+ return { reason: `combined shell command exceeds ${MAX_SHELL_COMMAND_CHARS} characters` };
556
+ }
557
+ return { command: combined };
558
+ }