@staix/agent-hub 0.12.45 → 0.13.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/CHANGELOG.md +18 -0
- package/README.md +83 -116
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -7
- package/docs/agent-notes/budget.md +6 -1
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +6 -1
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +10 -0
- package/docs/agent-notes/tests.md +4 -2
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +250 -19
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +8 -2
- package/docs/sdlc.md +36 -6
- package/docs/security.md +20 -11
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +759 -8
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +14 -14
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +613 -329
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +20 -9
- package/src/adapters/codex-appserver.ts +18 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -1
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +2 -1
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/registry-types.ts +3 -1
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +16 -6
- package/src/cli/identity.ts +29 -6
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +122 -11
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +43 -8
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +10 -1
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/daemon.ts +237 -22
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/hub-tools.ts +24 -2
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instances.ts +73 -1
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/sdlc-queue.ts +31 -9
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc.ts +49 -11
- package/src/hub/settings.ts +5 -2
- package/src/hub/snapshots.ts +93 -8
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +292 -54
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/cli/output.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, termin
|
|
|
5
5
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
6
6
|
import { backendLabel } from "./status-lines.ts";
|
|
7
7
|
import { formatReport, formatTaskReport, type Report, type TaskReport } from "../hub/report.ts";
|
|
8
|
+
import type { Counter, LimitsResult, TotalResult, UsageResult } from "../hub/usage-rollup.ts";
|
|
8
9
|
|
|
9
10
|
export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
|
|
10
11
|
export interface StatusOutput {
|
|
@@ -17,7 +18,7 @@ export interface StatusOutput {
|
|
|
17
18
|
isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
|
|
18
19
|
memoryProfile?: { profile?: string; generation?: string; state?: string; agentMemVersion?: string; skippedRows?: number; error?: string };
|
|
19
20
|
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
20
|
-
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
21
|
+
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; actionPolicyInfo?: Record<string, { note: string; enforceable: boolean }>; budget?: Record<string, any>; planner?: { peers?: string[]; source?: string };
|
|
21
22
|
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[];
|
|
22
23
|
}
|
|
23
24
|
export interface BudgetOutput { budget: Record<string, any>; gate?: number }
|
|
@@ -245,9 +246,12 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
245
246
|
}
|
|
246
247
|
if (p.toolsOnly) out.push(...detail(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
|
|
247
248
|
if (p.pullOnly && definitionOf(id)?.attach?.pullOnlyForms?.includes("tui")) out.push(...detail(`${id} last read`, typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
|
|
249
|
+
const actionPolicy = data.actionPolicyInfo?.[id];
|
|
250
|
+
if (actionPolicy?.note) out.push(...detail(`${id} action policy`, actionPolicy.note, columns, actionPolicy.enforceable ? "muted" : "attention"));
|
|
248
251
|
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
249
252
|
if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
|
|
250
253
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
254
|
+
if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
|
|
251
255
|
if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
|
|
252
256
|
if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
|
|
253
257
|
return out;
|
|
@@ -431,3 +435,42 @@ export function renderReport(report: Report | TaskReport, columns?: number, now
|
|
|
431
435
|
}
|
|
432
436
|
return out;
|
|
433
437
|
}
|
|
438
|
+
|
|
439
|
+
// ---- ahub usage (#683 U2): every number names its source; an unknown number prints `unknown`, never 0 ----------------------------------------------
|
|
440
|
+
const SOURCE_NAME: Record<Counter["source"], string> = { native: "native", count: "hub count", manual: "manual", partial: "partial, at least", unknown: "unknown" };
|
|
441
|
+
/** `1234 (native)`, `1234 (hub count)`, or `unknown`: a missing counter is never shown as zero. */
|
|
442
|
+
export const usageNumber = (c: Counter): string => c.value === null ? "unknown" : `${c.value} (${SOURCE_NAME[c.source]})`;
|
|
443
|
+
const usageCell = (c: Counter): OutputCell => c.value === null ? span("unknown", "muted") : span(usageNumber(c), "number");
|
|
444
|
+
|
|
445
|
+
export function renderUsage(result: UsageResult, columns?: number): Span[][] {
|
|
446
|
+
const out = lines(`usage by ${result.by}: ${result.coverage.records} records, ${result.coverage.withUsage} with provider counters, ${result.coverage.withoutUsage} without`, columns, "muted");
|
|
447
|
+
const header = ["KEY", "TOTAL", "WITHOUT CACHE", "CACHE READ", "INPUT", "OUTPUT", "REASONING", "HUB COUNT"];
|
|
448
|
+
const row = (r: UsageResult["rows"][number]) => [span(r.pii ? `${r.key} [pii]` : r.key), usageCell(r.total), usageCell(r.totalWithoutCache), usageCell(r.cacheRead), usageCell(r.input), usageCell(r.output), usageCell(r.reasoning), usageCell(r.counted)];
|
|
449
|
+
const all = [...result.rows, ...(result.unattributed ? [result.unattributed] : [])];
|
|
450
|
+
if (!all.length) return [...out, ...lines("no usage recorded in this period", columns)];
|
|
451
|
+
return [...out, [], ...rows(header, all.map(row), columns, undefined, true),
|
|
452
|
+
...lines("TOTAL is the provider's own total, cache reads included; WITHOUT CACHE removes them. HUB COUNT is what the hub counted itself and covers peers that report no usage record. unknown is never zero.", columns, "muted")];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function renderLimits(result: LimitsResult, current: unknown, columns?: number): Span[][] {
|
|
456
|
+
const out = lines(`limit history: ${result.truncated ? `the newest ${result.shown} of ${result.total}` : `${result.total}`} events, ${result.open.length} open pause${result.open.length === 1 ? "" : "s"}`, columns, "muted");
|
|
457
|
+
if (result.truncated) out.push(...lines(`${result.omitted} older events are not shown: use --limit <n> (up to ${result.maxLimit}) for more`, columns, "attention"));
|
|
458
|
+
for (const o of result.open) out.push(...lines(`open pause: ${o.peer} ${o.window} since ${o.since}, resets ${new Date(o.resetsAt).toISOString()}`, columns, "attention"));
|
|
459
|
+
for (const o of result.endedUnknown) out.push(...lines(`pause ended, end unknown: ${o.peer} ${o.window} since ${o.since} (the hub no longer holds it, for example after ahub reset; no resume was recorded)`, columns, "muted"));
|
|
460
|
+
const windows = current && typeof current === "object" ? Object.entries(current as Record<string, { windows?: { id: string; used: number; resetsAt?: number }[] }>) : [];
|
|
461
|
+
const live = windows.flatMap(([peer, b]) => (b?.windows ?? []).map(w => [span(peer), span(w.id), span(`${Math.round(w.used * 100)}%`, "number"), span(w.resetsAt ? new Date(w.resetsAt).toISOString() : "unknown", w.resetsAt ? undefined : "muted")]));
|
|
462
|
+
if (live.length) out.push([], ...lines("current windows", columns, "strong"), ...rows(["PEER", "WINDOW", "USED", "RESETS"], live, columns, undefined, true));
|
|
463
|
+
const hist = result.history.map(h => [span(h.at), span(h.type.replace("budget_", "").replace("quota_reset", "reset")), span(h.peer), span(h.window), span(Object.entries(h.detail).map(([k, v]) => `${k} ${v}`).join(", "), "muted")]);
|
|
464
|
+
if (hist.length) out.push([], ...lines("history", columns, "strong"), ...rows(["AT", "EVENT", "PEER", "WINDOW", "DETAIL"], hist, columns, undefined, true));
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function renderTotal(result: TotalResult, columns?: number): Span[][] {
|
|
469
|
+
const out = lines(`lifetime development tokens since ${result.since ?? "the first event"}`, columns, "muted");
|
|
470
|
+
const table = result.peers.map(p => [span(p.peer), usageCell(p.tokens), usageCell(p.withoutCache), usageCell(p.cacheRead)]);
|
|
471
|
+
table.push([span("ALL PEERS", "strong"), usageCell(result.overall.tokens), usageCell(result.overall.withoutCache), usageCell(result.overall.cacheRead)]);
|
|
472
|
+
const left = (label: string, peers: string[]) => peers.length ? lines(`${label} leaves out ${peers.join(", ")} (unknown, not counted as 0): a lower bound`, columns, "attention") : [];
|
|
473
|
+
return [...out, [], ...rows(["PEER", "TOTAL", "WITHOUT CACHE", "CACHE READ"], table, columns, undefined, true),
|
|
474
|
+
...left("ALL PEERS TOTAL", result.overall.tokens.source === "partial" ? result.unknownPeers.tokens : []), ...left("ALL PEERS CACHE READ", result.overall.cacheRead.source === "partial" ? result.unknownPeers.cacheRead : []),
|
|
475
|
+
...lines("A peer with no provider counters (for example jcode, kimi) shows the hub's own count where one exists, otherwise unknown. The overall WITHOUT CACHE is unknown unless every peer's cache split is known.", columns, "muted")];
|
|
476
|
+
}
|
package/src/cli/preview.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen
|
|
|
21
21
|
if (mode >= 0 && args[mode + 1] !== "headless") throw new Error("extra instances require headless mode");
|
|
22
22
|
if (mode < 0 && !args.includes("--headless")) args.push("--mode", "headless");
|
|
23
23
|
}
|
|
24
|
-
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition,
|
|
24
|
+
const result = preview({ tool, args, cwd, stateDir, cliDir: import.meta.dir, unattended, config, definition, ...(selected.instance ? { instance: selected.instance } : {}),
|
|
25
25
|
baseEnvNames: Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]),
|
|
26
26
|
helpers: { buildLaunch: (native, argv, ctx) => buildLaunch(native, argv, { ...ctx, ...(selected.instance ? { instance: selected.instance } : {}) }), claudeObservationHooks, buildKimiLaunch, buildKimiTuiLaunch, nativeLaunchEnv, childEnv,
|
|
27
27
|
startModeOf, kimiMcpPlan, jcodeCommand, jcodeTuiCommand, qwenSessionId, relayModelIds, buildPiLaunch },
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Literal release targets only. Parsing never executes a command or reads the repository. */
|
|
2
|
+
export type ReleaseGuardRequest =
|
|
3
|
+
| { kind: "tag-push"; version: string; tag: string; sourceRef: string; remote: string }
|
|
4
|
+
| { kind: "gh-release"; version: string; tag: string; target?: string }
|
|
5
|
+
| { kind: "npm-publish" };
|
|
6
|
+
export interface ReleasePushUpdate { localRef: string; localOid: string; remoteRef: string; remoteOid: string; version: string; }
|
|
7
|
+
type Refusal = { ok: false; reason: string };
|
|
8
|
+
export type ReleaseGuardParse = { ok: true; request: ReleaseGuardRequest } | Refusal;
|
|
9
|
+
export type ReleasePushParse = { ok: true; remoteName: string; remoteUrl: string; updates: ReleasePushUpdate[]; stdin: string } | Refusal;
|
|
10
|
+
const refuse = (): Refusal => ({ ok: false, reason: "release target is ambiguous, unsupported or invalid" });
|
|
11
|
+
const SEMVER = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$/;
|
|
12
|
+
const oid = (value: string) => /^[0-9a-f]{40}$/.test(value);
|
|
13
|
+
const zero = (value: string) => /^0{40}$/.test(value);
|
|
14
|
+
const literal = (value: string) => value.length > 0 && value.length <= 4096 && !/[\s\0-\x1f\x7f$`*?\\]/.test(value);
|
|
15
|
+
const ref = (value: string) => literal(value) && !value.startsWith("-") && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.endsWith(".lock") && !"~^:[]()".split("").some(char => value.includes(char));
|
|
16
|
+
const versionOf = (tag: string): string | undefined => tag.startsWith("v") && SEMVER.test(tag.slice(1)) ? tag.slice(1) : undefined;
|
|
17
|
+
|
|
18
|
+
// Hook values are opaque Git data, not shell source. Unicode and shell-looking
|
|
19
|
+
// characters are valid in refs; only Git's forbidden ref syntax is rejected.
|
|
20
|
+
const hookText = (value: string) => value.length > 0 && value.length <= 4096 && !/[\p{Cc}]/u.test(value);
|
|
21
|
+
const hookRef = (value: string) => hookText(value) && !/[\0-\x20\x7f]/.test(value) && !"~^:?*[\\".split("").some(char => value.includes(char)) && !value.includes("..") && !value.includes("@{") && !value.includes("//") && !value.endsWith("/") && !value.endsWith(".") && !value.split("/").some(part => !part || part.startsWith(".") || part.endsWith(".lock"));
|
|
22
|
+
|
|
23
|
+
export function parseReleaseGuardArgv(argv: readonly string[]): ReleaseGuardParse {
|
|
24
|
+
if (!Array.isArray(argv) || !argv.length || argv.length > 128 || !argv.every(word => typeof word === "string" && word.length <= 4096 && !/[\0-\x1f\x7f]/.test(word)) || Buffer.byteLength(argv.join("\0")) > 16 * 1024) return refuse();
|
|
25
|
+
const [program, ...args] = argv;
|
|
26
|
+
if (program === "git" && args[0] === "push") {
|
|
27
|
+
if (args.length !== 3 || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[1] ?? "")) return refuse();
|
|
28
|
+
const spec = args[2]!, parts = spec.split(":");
|
|
29
|
+
if (parts.length > 2 || spec.startsWith("+")) return refuse();
|
|
30
|
+
const destination = parts.length === 2 ? parts[1]! : parts[0]!;
|
|
31
|
+
const tag = destination.startsWith("refs/tags/") ? destination.slice(10) : destination;
|
|
32
|
+
const version = versionOf(tag), sourceRef = parts[0]!;
|
|
33
|
+
if (!version || !ref(sourceRef) || parts.length === 2 && !destination.startsWith("refs/tags/")) return refuse();
|
|
34
|
+
return { ok: true, request: { kind: "tag-push", version, tag, sourceRef, remote: args[1]! } };
|
|
35
|
+
}
|
|
36
|
+
if (program === "gh" && args[0] === "release" && args[1] === "create") {
|
|
37
|
+
const tag = args[2] ?? "", version = versionOf(tag);
|
|
38
|
+
if (!version) return refuse();
|
|
39
|
+
let target: string | undefined;
|
|
40
|
+
const valued = new Set(["--title", "-t", "--notes", "-n", "--notes-file", "-F", "--notes-start-tag", "--target"]);
|
|
41
|
+
const flags = new Set(["--verify-tag", "--generate-notes", "--draft", "-d", "--prerelease", "-p", "--fail-on-no-commits"]);
|
|
42
|
+
for (let i = 3; i < args.length; i++) {
|
|
43
|
+
const flag = args[i]!;
|
|
44
|
+
if (flags.has(flag)) continue;
|
|
45
|
+
if (!valued.has(flag) || args[i + 1] === undefined) return refuse();
|
|
46
|
+
const value = args[++i]!;
|
|
47
|
+
if (flag === "--target") { if (target !== undefined || !ref(value)) return refuse(); target = value; }
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, request: { kind: "gh-release", version, tag, ...(target === undefined ? {} : { target }) } };
|
|
50
|
+
}
|
|
51
|
+
if (program === "npm" && args[0] === "publish") {
|
|
52
|
+
const flags = new Set(["--provenance", "--ignore-scripts", "--dry-run"]);
|
|
53
|
+
for (let i = 1; i < args.length; i++) {
|
|
54
|
+
if (flags.has(args[i]!)) continue;
|
|
55
|
+
if (args[i] === "--access" && ["public", "restricted"].includes(args[i + 1] ?? "")) { i++; continue; }
|
|
56
|
+
if (args[i] === "--tag" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(args[i + 1] ?? "")) { i++; continue; }
|
|
57
|
+
return refuse();
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, request: { kind: "npm-publish" } };
|
|
60
|
+
}
|
|
61
|
+
return refuse();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Validate every hook row before selecting version-tag updates. Preserve the exact original input. */
|
|
65
|
+
export function parseReleasePushUpdates(remoteName: string, remoteUrl: string, stdin: string): ReleasePushParse {
|
|
66
|
+
if (typeof remoteName !== "string" || typeof remoteUrl !== "string" || typeof stdin !== "string" || !hookText(remoteName) || !hookText(remoteUrl) || Buffer.byteLength(stdin) > 128 * 1024) return refuse();
|
|
67
|
+
const lines = stdin.endsWith("\n") ? stdin.slice(0, -1).split("\n") : stdin.split("\n");
|
|
68
|
+
if (stdin === "") lines.length = 0;
|
|
69
|
+
if (lines.length > 128) return refuse();
|
|
70
|
+
const updates: ReleasePushUpdate[] = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const fields = line.split(" ");
|
|
73
|
+
if (fields.length !== 4) return refuse();
|
|
74
|
+
const [localRef, localOid, remoteRef, remoteOid] = fields as [string, string, string, string];
|
|
75
|
+
if ((!hookRef(localRef) && localRef !== "(delete)") || !hookRef(remoteRef) || !remoteRef.startsWith("refs/") || !oid(localOid) || !oid(remoteOid)) return refuse();
|
|
76
|
+
if (!remoteRef.startsWith("refs/tags/v")) continue;
|
|
77
|
+
const version = versionOf(remoteRef.slice(10));
|
|
78
|
+
if (!version || zero(localOid) || localRef === "(delete)") return refuse();
|
|
79
|
+
updates.push({ localRef, localOid, remoteRef, remoteOid, version });
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, remoteName, remoteUrl, updates, stdin };
|
|
82
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export const ACTION_CLASSES = ["publish", "external-write", "destroy", "install"] as const;
|
|
4
|
+
export type ActionClass = typeof ACTION_CLASSES[number];
|
|
5
|
+
|
|
6
|
+
const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
|
|
7
|
+
const LIMIT = 16_384;
|
|
8
|
+
type Command = { words: string[]; outputs: string[] };
|
|
9
|
+
|
|
10
|
+
/** Bound the whole input before accepting any prefix, including unsupported suffixes. */
|
|
11
|
+
function segmentLimit(text: string): boolean {
|
|
12
|
+
let quote = "", count = 0;
|
|
13
|
+
for (let i = 0; i < text.length; i++) {
|
|
14
|
+
const c = text[i]!;
|
|
15
|
+
if (c === "\\" && quote !== "'") { i++; continue; }
|
|
16
|
+
if (quote) { if (quote === c) quote = ""; continue; }
|
|
17
|
+
if (c === "'" || c === '"') { quote = c; continue; }
|
|
18
|
+
if (c === "#" && (i === 0 || /[ \t\r\n;]/.test(text[i - 1]!))) {
|
|
19
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
20
|
+
}
|
|
21
|
+
if (i < text.length && ";|&\n".includes(text[i]!)) {
|
|
22
|
+
// FD duplication is an operator, not another command.
|
|
23
|
+
if (text[i] === "&" && [">", "<"].includes(text[i - 1] ?? "")) continue;
|
|
24
|
+
if (++count > 64) return false;
|
|
25
|
+
if ((c === "|" || c === "&") && text[i + 1] === c) i++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Literal shell words only; unsupported syntax discards only its unfinished segment. */
|
|
32
|
+
function commands(text: string): Command[] | undefined {
|
|
33
|
+
if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
|
|
34
|
+
const result: Command[] = [];
|
|
35
|
+
let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
|
|
36
|
+
let redirect: false | "overwrite" | "skip" = false;
|
|
37
|
+
const flush = () => {
|
|
38
|
+
if (!active) return;
|
|
39
|
+
if (redirect === "overwrite") outputs.push(word);
|
|
40
|
+
else if (!redirect) words.push(word);
|
|
41
|
+
word = ""; active = false; redirect = false;
|
|
42
|
+
};
|
|
43
|
+
const finish = () => { flush(); if (words.length || outputs.length) result.push({ words, outputs }); words = []; outputs = []; };
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const c = text[i]!;
|
|
46
|
+
if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
|
|
47
|
+
if (c === "\\") {
|
|
48
|
+
const next = text[++i];
|
|
49
|
+
if (next === undefined) return result;
|
|
50
|
+
if (quote === '"' && !['$', '`', '"', "\\", "\n"].includes(next)) word += "\\";
|
|
51
|
+
if (next !== "\n") word += next;
|
|
52
|
+
active = true; continue;
|
|
53
|
+
}
|
|
54
|
+
if (c === "$" || c === "`") return result;
|
|
55
|
+
if (quote) { if (c === quote) quote = ""; else word += c; continue; }
|
|
56
|
+
if (c === "'" || c === '"') { quote = c; active = true; continue; }
|
|
57
|
+
if (c === "#" && !active) { while (i < text.length && text[i] !== "\n") i++; finish(); continue; }
|
|
58
|
+
if ("(){}".includes(c)) {
|
|
59
|
+
if (redirect && !active) return result;
|
|
60
|
+
finish(); continue;
|
|
61
|
+
}
|
|
62
|
+
if (c === ">" || c === "<") {
|
|
63
|
+
if (redirect && !active) return result;
|
|
64
|
+
if (redirect) flush();
|
|
65
|
+
if (c === "<" && text[i + 1] === "<") return result;
|
|
66
|
+
if (active && /^\d+$/.test(word)) { word = ""; active = false; } else flush();
|
|
67
|
+
if (text[i + 1] === "&") {
|
|
68
|
+
const duplicate = text.slice(i + 2).match(/^(?:\d+|-)(?=$|[ \t\r\n;|&(){}<>])/);
|
|
69
|
+
if (!duplicate) return result;
|
|
70
|
+
i += 1 + duplicate[0].length; continue;
|
|
71
|
+
}
|
|
72
|
+
redirect = c === "<" || text[i + 1] === ">" ? "skip" : "overwrite";
|
|
73
|
+
if (text[i + 1] === ">" || text[i + 1] === "|") i++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (/[ \t\r]/.test(c)) { flush(); continue; }
|
|
77
|
+
if (";|&\n".includes(c)) {
|
|
78
|
+
if (redirect && !active) return result;
|
|
79
|
+
finish();
|
|
80
|
+
if (text[i + 1] === c && (c === "|" || c === "&")) i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
word += c; active = true;
|
|
84
|
+
if (words.length + outputs.length > 256) return result;
|
|
85
|
+
}
|
|
86
|
+
if (quote || (redirect && !active)) return result;
|
|
87
|
+
finish();
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
92
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
93
|
+
}
|
|
94
|
+
function outside(target: string, root: string, cwd: string): boolean {
|
|
95
|
+
if (!isAbsolute(root) || !target || /[$`]/.test(target)) return false;
|
|
96
|
+
if (target.startsWith("~") || !cwd && !isAbsolute(target)) return true;
|
|
97
|
+
const rel = relative(resolve(root), resolve(cwd, target));
|
|
98
|
+
return rel === ".." || rel.startsWith("../") || isAbsolute(rel);
|
|
99
|
+
}
|
|
100
|
+
function flags(words: string[], letter: string): boolean {
|
|
101
|
+
const end = words.indexOf("--");
|
|
102
|
+
return words.slice(0, end < 0 ? undefined : end).some(w => /^-[^-]/.test(w) && w.slice(1).includes(letter));
|
|
103
|
+
}
|
|
104
|
+
function operands(words: string[]): string[] {
|
|
105
|
+
const end = words.indexOf("--");
|
|
106
|
+
return end < 0 ? words.filter(w => !w.startsWith("-")) : [...words.slice(0, end).filter(w => !w.startsWith("-")), ...words.slice(end + 1)];
|
|
107
|
+
}
|
|
108
|
+
function option(words: string[], short: string, long: string): string | undefined {
|
|
109
|
+
for (let i = 0; i < words.length; i++) {
|
|
110
|
+
const w = words[i]!;
|
|
111
|
+
if (w === "--") break;
|
|
112
|
+
if (w === short || w === long) return words[i + 1];
|
|
113
|
+
if (w.startsWith(`${long}=`)) return w.slice(long.length + 1);
|
|
114
|
+
if (w.startsWith(short) && w.length > short.length) return w.slice(short.length);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
/** Known option values are opaque. Unknown prefix options retain every plausible action head. */
|
|
119
|
+
function commandHeads(words: string[], values: readonly string[], booleans: readonly string[], actions: readonly string[]): Array<{ name: string; args: string[] }> {
|
|
120
|
+
const heads: Array<{ name: string; args: string[] }> = [];
|
|
121
|
+
let uncertain = false, literal = false;
|
|
122
|
+
for (let i = 0; i < words.length; i++) {
|
|
123
|
+
const word = words[i]!;
|
|
124
|
+
if (!literal && word === "--") { literal = true; continue; }
|
|
125
|
+
if (!literal && word.startsWith("-")) {
|
|
126
|
+
if (!uncertain && ["--help", "--version", "-h", "-V"].includes(word)) return [];
|
|
127
|
+
if (values.includes(word)) { if (!uncertain) i++; continue; }
|
|
128
|
+
if (values.some(key => key.startsWith("--") ? word.startsWith(`${key}=`) : word.startsWith(key) && word.length > key.length)) continue;
|
|
129
|
+
if (!booleans.includes(word)) uncertain = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!uncertain) return [{ name: word, args: words.slice(i + 1) }];
|
|
133
|
+
if (actions.includes(word)) heads.push({ name: word, args: words.slice(i + 1) });
|
|
134
|
+
}
|
|
135
|
+
return heads;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Exclude patterns and path operands cannot impersonate clean's force/dry-run options. */
|
|
139
|
+
function forcedClean(words: string[]): boolean {
|
|
140
|
+
let force = false, dry = false;
|
|
141
|
+
for (let i = 0; i < words.length; i++) {
|
|
142
|
+
const word = words[i]!;
|
|
143
|
+
if (word === "--") break;
|
|
144
|
+
if (word === "--exclude") { i++; continue; }
|
|
145
|
+
if (word.startsWith("--exclude=")) continue;
|
|
146
|
+
if (word === "--force") force = true;
|
|
147
|
+
else if (word === "--dry-run") dry = true;
|
|
148
|
+
else if (/^-[^-]/.test(word)) {
|
|
149
|
+
for (let at = 1; at < word.length; at++) {
|
|
150
|
+
const flag = word[at]!;
|
|
151
|
+
if (flag === "e") { if (at === word.length - 1) i++; break; }
|
|
152
|
+
if (flag === "f") force = true;
|
|
153
|
+
if (flag === "n") dry = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return force && !dry;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Peel only known transparent argv wrappers; no aliases or executable discovery. */
|
|
161
|
+
function unwrapped(input: string[]): string[] {
|
|
162
|
+
const words = [...input];
|
|
163
|
+
for (let step = 0; step < 32; step++) {
|
|
164
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? "")) words.shift();
|
|
165
|
+
const name = basename(words[0] ?? "");
|
|
166
|
+
if (["!", "if", "then", "elif", "else"].includes(name)) { words.shift(); continue; }
|
|
167
|
+
if (["fi", "do", "done"].includes(name)) return [];
|
|
168
|
+
if (!["sudo", "time", "timeout", "nohup", "exec", "xargs", "nice", "env", "command"].includes(name)) return words;
|
|
169
|
+
words.shift();
|
|
170
|
+
const values: Record<string, string[]> = {
|
|
171
|
+
sudo: ["-u", "-g", "-h", "-p", "-C", "-T", "--user", "--group", "--host", "--prompt"],
|
|
172
|
+
time: ["-f", "-o", "--format", "--output"],
|
|
173
|
+
timeout: ["-s", "-k", "--signal", "--kill-after"],
|
|
174
|
+
exec: ["-a"], xargs: ["-n", "-P", "-I", "-L", "-s", "-E", "-d", "--max-args", "--max-procs", "--replace", "--delimiter"],
|
|
175
|
+
nice: ["-n", "--adjustment"], env: ["-u", "--unset"],
|
|
176
|
+
};
|
|
177
|
+
while (words[0]?.startsWith("-")) {
|
|
178
|
+
const flag = words.shift()!;
|
|
179
|
+
if (flag === "--") break;
|
|
180
|
+
if (name === "command" && (flag === "-v" || flag === "-V")) return [];
|
|
181
|
+
if ((values[name] ?? []).includes(flag)) { if (!words.length) return []; words.shift(); }
|
|
182
|
+
else if (flag.startsWith("--") && !flag.includes("=") && !["--ignore-environment", "--foreground", "--preserve-status", "--no-run-if-empty", "--verbose"].includes(flag)) return [];
|
|
183
|
+
}
|
|
184
|
+
if (name === "timeout") {
|
|
185
|
+
if (!/^\d+(?:\.\d+)?[smhd]?$/.test(words.shift() ?? "")) return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Scan curl options once: data, headers and other option values never become options. */
|
|
192
|
+
function curlWrites(input: string[]): boolean {
|
|
193
|
+
const longValues = new Set([
|
|
194
|
+
"request", "data", "data-ascii", "data-binary", "data-raw", "data-urlencode", "json", "form", "form-string", "upload-file",
|
|
195
|
+
"url-query", "header", "proxy-header", "output", "output-dir", "user-agent", "user", "proxy-user", "cookie", "cookie-jar", "referer", "url",
|
|
196
|
+
"resolve", "connect-to", "config", "cacert", "capath", "cert", "cert-type", "key", "key-type", "pass", "proxy", "preproxy",
|
|
197
|
+
"interface", "max-time", "connect-timeout", "limit-rate", "range", "request-target", "unix-socket", "abstract-unix-socket",
|
|
198
|
+
"write-out", "trace", "trace-ascii", "dump-header", "stderr", "netrc-file", "ftp-method", "ftp-port", "oauth2-bearer",
|
|
199
|
+
"proxy-cacert", "proxy-cert", "proxy-key", "socks5", "socks5-hostname", "retry", "retry-delay", "retry-max-time", "local-port",
|
|
200
|
+
]);
|
|
201
|
+
let method: string | undefined, data = false, upload = false, get = false, head = false, uncertain = false, writeMethod = false;
|
|
202
|
+
const booleanOptions = new Set(["get", "head", "compressed", "location", "insecure", "silent", "show-error", "fail", "fail-with-body", "globoff", "verbose", "include", "remote-name", "path-as-is", "create-dirs", "progress-meter", "progress-bar", "http1.1", "http2", "http3"]);
|
|
203
|
+
// An unknown option may consume a following read-looking token. It cannot erase known write evidence.
|
|
204
|
+
const writes = () => uncertain && (data || upload || writeMethod) || (method !== undefined ? !["GET", "HEAD"].includes(method.toUpperCase()) : !head && (upload || data && !get));
|
|
205
|
+
for (let i = 0; i < input.length; i++) {
|
|
206
|
+
const arg = input[i]!;
|
|
207
|
+
if (arg === "--") break;
|
|
208
|
+
if (arg === "--next" || arg === "-:") {
|
|
209
|
+
if (writes()) return true;
|
|
210
|
+
method = undefined; data = upload = get = head = uncertain = writeMethod = false;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (arg.startsWith("--")) {
|
|
214
|
+
const equal = arg.indexOf("=");
|
|
215
|
+
const name = arg.slice(2, equal < 0 ? undefined : equal);
|
|
216
|
+
if (name === "get" || name === "no-get") get = name === "get";
|
|
217
|
+
else if (name === "head" || name === "no-head") head = name === "head";
|
|
218
|
+
if (!longValues.has(name)) { if (!booleanOptions.has(name.replace(/^no-/, ""))) uncertain = true; continue; }
|
|
219
|
+
const value = equal < 0 ? input[++i] : arg.slice(equal + 1);
|
|
220
|
+
if (value === undefined) continue;
|
|
221
|
+
if (name === "request") { method = value; writeMethod ||= !["GET", "HEAD"].includes(value.toUpperCase()); }
|
|
222
|
+
else if (name === "upload-file") upload = true;
|
|
223
|
+
else if (name === "json" || name.startsWith("data") || name === "form" || name === "form-string") data = true;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (!/^-[^-]/.test(arg)) continue;
|
|
227
|
+
for (let at = 1; at < arg.length; at++) {
|
|
228
|
+
const flag = arg[at]!;
|
|
229
|
+
if (flag === "G") get = true;
|
|
230
|
+
if (flag === "I") head = true;
|
|
231
|
+
// These short options consume the rest of this word, or the next whole word.
|
|
232
|
+
if (!"XdFTHoAuUbecrxEKPmCwDQztYy".includes(flag)) continue;
|
|
233
|
+
const value = arg.slice(at + 1) || input[++i];
|
|
234
|
+
if (value !== undefined) {
|
|
235
|
+
if (flag === "X") { method = value; writeMethod ||= !["GET", "HEAD"].includes(value.toUpperCase()); }
|
|
236
|
+
else if (flag === "d" || flag === "F") data = true;
|
|
237
|
+
else if (flag === "T") upload = true;
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return writes();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sequence(parsed: Command[], root: string, initial: string[], found: Set<ActionClass>, depth = 0): void {
|
|
246
|
+
const cwd = new Set(initial);
|
|
247
|
+
for (const command of parsed) {
|
|
248
|
+
classify(command, root, [...cwd], found, depth);
|
|
249
|
+
const [program, ...args] = unwrapped(command.words);
|
|
250
|
+
const target = args[0] === "--" ? args[1] : args[0];
|
|
251
|
+
// Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
|
|
252
|
+
if (program === "cd" && (!target || target.startsWith("~"))) {
|
|
253
|
+
if (!cwd.has("") && cwd.size >= 128) cwd.delete(cwd.values().next().value!);
|
|
254
|
+
cwd.add("");
|
|
255
|
+
}
|
|
256
|
+
else if (program === "cd" && target && !target.startsWith("-") && !"$`*?[]".split("").some(c => target.includes(c))) {
|
|
257
|
+
for (const current of [...cwd]) {
|
|
258
|
+
if (cwd.size >= 128) break;
|
|
259
|
+
cwd.add(current || isAbsolute(target) ? resolve(current, target) : "");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
|
|
265
|
+
const words = unwrapped(command.words);
|
|
266
|
+
const program = basename(words.shift() ?? "");
|
|
267
|
+
if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
|
|
268
|
+
const shell = [...words];
|
|
269
|
+
while (["--noprofile", "--norc"].includes(shell[0] ?? "")) shell.shift();
|
|
270
|
+
const mode = shell.shift();
|
|
271
|
+
if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
|
|
272
|
+
if (shell[0] === "--") shell.shift();
|
|
273
|
+
const nested = shell[0] === undefined ? undefined : commands(shell[0]);
|
|
274
|
+
if (nested) sequence(nested, root, cwd, found, depth + 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (command.outputs.some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
278
|
+
if (program === "git") {
|
|
279
|
+
const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
|
|
280
|
+
["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
|
|
281
|
+
for (const { name: sub, args } of heads) {
|
|
282
|
+
if (sub === "push") found.add("publish");
|
|
283
|
+
if (sub === "reset" && args.some(w => w === "--hard" || w.startsWith("--hard="))) found.add("destroy");
|
|
284
|
+
if (sub === "clean" && forcedClean(args)) found.add("destroy");
|
|
285
|
+
if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force"))) found.add("destroy");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (["npm", "cargo", "pnpm", "yarn", "bun"].includes(program)) {
|
|
289
|
+
const valuesByProgram: Record<string, string[]> = {
|
|
290
|
+
npm: ["--registry", "--prefix", "-C", "--cache", "--userconfig", "--globalconfig", "--loglevel", "--workspace", "-w"],
|
|
291
|
+
pnpm: ["--registry", "--dir", "-C", "--filter", "-F", "--store-dir", "--reporter", "--loglevel"],
|
|
292
|
+
yarn: ["--registry", "--cwd", "--cache-folder", "--mutex", "--use-yarnrc", "--modules-folder", "--network-concurrency", "--network-timeout"],
|
|
293
|
+
cargo: ["--color", "--config", "--manifest-path", "--target-dir"],
|
|
294
|
+
bun: ["--registry", "--cwd", "-C", "--filter", "-F", "--env-file", "--config", "-c", "--cache-dir"],
|
|
295
|
+
};
|
|
296
|
+
const values = valuesByProgram[program]!;
|
|
297
|
+
const booleans = ["--global", "-g", "--offline", "--locked", "--frozen", "--silent", "--quiet", "-q", "--verbose", "-v"];
|
|
298
|
+
const actions = ["publish", "install", "i", "add", "global", "npm"];
|
|
299
|
+
const heads = commandHeads(words, values, booleans, actions);
|
|
300
|
+
for (const head of heads) {
|
|
301
|
+
const candidates = program === "yarn" && head.name === "npm" ? commandHeads(head.args, values, booleans, actions) : [head];
|
|
302
|
+
for (const { name: sub, args } of candidates) {
|
|
303
|
+
if (sub === "publish") found.add("publish");
|
|
304
|
+
const global = words.includes("--global") || flags(words, "g");
|
|
305
|
+
if ((program === "cargo" && sub === "install") || (["install", "i", "add"].includes(sub) && global) || (program === "yarn" && sub === "global" && args[0] === "add")) found.add("install");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (program === "pipx" && words[0] === "install" || program === "brew" && ["install", "reinstall"].includes(words[0] ?? "") || ["pip", "pip3"].includes(program) && words[0] === "install" && words.includes("--user")) found.add("install");
|
|
310
|
+
if (program === "gh") {
|
|
311
|
+
if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
|
|
312
|
+
if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? "")) found.add("external-write");
|
|
313
|
+
if (words[0] === "api") {
|
|
314
|
+
const method = option(words, "-X", "--method");
|
|
315
|
+
const field = words.some(w => w === "-f" || w === "-F" || w.startsWith("-f") || w.startsWith("-F") || w === "--input" || w.startsWith("--input=") || w.startsWith("--field") || w.startsWith("--raw-field"));
|
|
316
|
+
if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field) found.add("external-write");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (program === "curl" && curlWrites(words)) found.add("external-write");
|
|
320
|
+
if (["http", "https"].includes(program) && words.some(w => /^(?:POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)$/.test(w))) found.add("external-write");
|
|
321
|
+
if (["mail", "mailx", "sendmail"].includes(program) && operands(words).length > 0) found.add("external-write");
|
|
322
|
+
if (["slack", "telegram", "discord"].includes(program) && words.some(w => ["send", "post", "chat.postMessage"].includes(w))) found.add("external-write");
|
|
323
|
+
if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive")) && operands(words).some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
|
|
324
|
+
if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force"))) {
|
|
325
|
+
const target = option(words, "-t", "--target-directory") ?? operands(words).at(-1);
|
|
326
|
+
if (target && cwd.some(at => outside(target, root, at))) found.add("destroy");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Adds information only; an empty result always retains the native permission policy.
|
|
331
|
+
* ponytail: literal bounded shell/argv forms only; aliases, functions, expansion,
|
|
332
|
+
* interpreter programs and symlink targets need a richer observed contract.
|
|
333
|
+
* Home targets are conservatively outside without resolving a personal home.
|
|
334
|
+
* Unsupported segments retain only preceding complete commands.
|
|
335
|
+
* Literal cd retains both success/failure cwd candidates (at most 128); it may
|
|
336
|
+
* overclassify a later relative write and is not a shell control-flow proof.
|
|
337
|
+
*/
|
|
338
|
+
export function classifyAction(tool: string, args: unknown, executionRoot: string): ActionClass[] {
|
|
339
|
+
if (typeof tool !== "string" || tool.length > 128 || typeof executionRoot !== "string" || executionRoot.length > LIMIT) return [];
|
|
340
|
+
if (typeof args === "string") {
|
|
341
|
+
if (args.length > LIMIT) return [];
|
|
342
|
+
try { args = JSON.parse(args) as unknown; } catch { args = { command: args }; }
|
|
343
|
+
}
|
|
344
|
+
const input = object(args);
|
|
345
|
+
if (!input) return [];
|
|
346
|
+
const found = new Set<ActionClass>();
|
|
347
|
+
if (["http", "http_request", "fetch"].includes(tool.toLowerCase()) && typeof input.method === "string" && /^[A-Za-z]+$/.test(input.method) && !["GET", "HEAD"].includes(input.method.toUpperCase())) found.add("external-write");
|
|
348
|
+
const boundedArgv = (value: unknown): value is string[] => Array.isArray(value) && value.length <= 256 && value.every(w => typeof w === "string" && w.length <= LIMIT) && value.join("").length <= LIMIT;
|
|
349
|
+
const location = input.cwd ?? input.workdir;
|
|
350
|
+
const cwd = typeof location === "string" && location.startsWith("~") ? "" : typeof location === "string" && location.length <= LIMIT && !/[$`]/.test(location) ? resolve(executionRoot, location) : executionRoot;
|
|
351
|
+
if (tool.toLowerCase() === "git" && boundedArgv(input.args)) classify({ words: ["git", ...input.args], outputs: [] }, executionRoot, [cwd], found);
|
|
352
|
+
if (SHELL_TOOLS.has(tool.toLowerCase())) {
|
|
353
|
+
const raw = input.command ?? input.cmd;
|
|
354
|
+
if (typeof raw === "string") {
|
|
355
|
+
const parsed = commands(raw);
|
|
356
|
+
if (parsed) sequence(parsed, executionRoot, [cwd], found);
|
|
357
|
+
} else if (boundedArgv(raw)) {
|
|
358
|
+
classify({ words: raw, outputs: [] }, executionRoot, [cwd], found);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return ACTION_CLASSES.filter(value => found.has(value));
|
|
362
|
+
}
|