@piagent/platform 1.47.0 → 1.48.1
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 +37 -0
- package/README.md +16 -3
- package/README.vi.md +16 -3
- package/docs/command-reference-vietnamese.md +4 -4
- package/docs/company-dev-commands.md +4 -4
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/security-threat-model.md +3 -1
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/assets/search-en.json +1 -1
- package/docs-site/assets/search-vi.json +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +15 -15
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/update.html +3 -3
- package/docs-site/en/whats-new.html +58 -31
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +15 -15
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/releases.json +87 -0
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/update.html +3 -3
- package/docs-site/whats-new.html +58 -31
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +2 -1
- package/packages/piagent-core/extensions/guard-types.ts +10 -2
- package/packages/piagent-core/extensions/piagent-guard.ts +110 -24
- package/packages/piagent-core/extensions/policy-core.js +7 -7
- package/packages/piagent-core/extensions/shell-ast.js +85 -0
- package/packages/piagent-core/extensions/shell-command-words.js +19 -0
- package/packages/piagent-core/managed/command-tools.mjs +54 -24
- package/packages/piagent-core/managed/fetch-guard.mjs +3 -3
- package/packages/piagent-core/managed/host-command.mjs +15 -7
- package/packages/piagent-core/managed/member-mcp.mjs +34 -9
- package/packages/piagent-core/managed/permission.mjs +19 -12
- package/packages/piagent-core/managed/repository-operation.mjs +1 -1
- package/packages/piagent-core/managed/session.mjs +2 -2
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +1 -1
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +33 -16
- package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +5 -1
- package/packages/piagent-core/runtime/policy/handoff-policy.mjs +144 -0
- package/packages/piagent-core/runtime/registration/permission-commands.ts +2 -2
- package/packages/piagent-core/runtime/solver/runtime-features.ts +52 -10
- package/packages/piagent-webui/dist/client/assets/{App-CiwY9VpU.js → App-fZeIRoaD.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{GatewayApp-DrNJW3v_.js → GatewayApp-CpC4lgYK.js} +17 -15
- package/packages/piagent-webui/dist/client/assets/{InspectionApp-BBiwSaV0.js → InspectionApp-pD-BqBXm.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionAgentWorkspace-DwyPebIX.js → SessionAgentWorkspace-3tM7Q6HH.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{SessionInspectorDrawer-CpVzaf_G.js → SessionInspectorDrawer-BJ94aQ6y.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-BFfqMcuL.js +3 -0
- package/packages/piagent-webui/dist/client/assets/SettingsPage-CxpZ7cdM.js +1 -0
- package/packages/piagent-webui/dist/client/assets/{TerminalPanel-B8j_r6-y.js → TerminalPanel-CtZyb0tj.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-BDTPvq-0.js → WorkspaceCodeEditor-BS_ZrjPN.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-DVsNU0eu.js → WorkspaceExplorer-CkFBpcM7.js} +3 -3
- package/packages/piagent-webui/dist/client/assets/{index-uhJMXxqD.js → index-CNdhIouM.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/{index-Dqb2hNan.css → index-CjM70M7D.css} +1 -1
- package/packages/piagent-webui/dist/client/assets/transcript-view-model-CpEMY1xb.js +3 -0
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/gateway/gateway-events.ts +5 -1
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +9 -4
- package/packages/piagent-webui/server/gateway-websocket.ts +5 -1
- package/schemas/piagent-webui/approval-v1.schema.json +3 -1
- package/schemas/project-profile.schema.json +16 -0
- package/scripts/company-terminal.mjs +5 -5
- package/scripts/verify-release-identity.mjs +1 -1
- package/packages/piagent-core/runtime/policy/bypass-policy.mjs +0 -42
- package/packages/piagent-webui/dist/client/assets/SettingsNavigation-C-tO5iXn.js +0 -3
- package/packages/piagent-webui/dist/client/assets/SettingsPage-Ca-qRSZa.js +0 -1
- package/packages/piagent-webui/dist/client/assets/transcript-view-model-Bq0nkxyu.js +0 -3
|
@@ -86,8 +86,9 @@ import { currentWorkspaceRevisionDigest } from "./workspace-revision.js";
|
|
|
86
86
|
import { appendJsonlBounded } from "./state-retention.js";
|
|
87
87
|
import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-state-path.js";
|
|
88
88
|
import { hasDurableContextEvidence } from "./context-evidence.js";
|
|
89
|
-
import { piApprovalBroker, type ApprovalActionDraft, type ApprovalUnavailableFallback } from "../runtime/inspection/approval-broker.ts";
|
|
89
|
+
import { piApprovalBroker, type ApprovalActionDraft, type ApprovalChoice, type ApprovalUnavailableFallback } from "../runtime/inspection/approval-broker.ts";
|
|
90
90
|
import { createSourceMutationGuardBindings } from "../runtime/policy/source-mutation-guard-binding.ts";
|
|
91
|
+
import { FULL_ACCESS_RUNS, handoffReason, mcpWriteAction, treeExecFinding } from "../runtime/policy/handoff-policy.mjs";
|
|
91
92
|
import { createWorkspaceWriteAuthorityBindings } from "../runtime/policy/workspace-write-authority.ts";
|
|
92
93
|
import { registerWorkspaceMemberEdits } from "../runtime/session/workspace-member-edits.mjs";
|
|
93
94
|
import {
|
|
@@ -147,7 +148,7 @@ import { parentRoutingModeFromEnvironment, routingObjectiveFromEnvironment } fro
|
|
|
147
148
|
import { ModelSelectionProvenanceTracker } from "../runtime/model/model-selection-provenance.ts";
|
|
148
149
|
import type { TaskFeatures } from "../runtime/solver/solver-types.ts";
|
|
149
150
|
import { planRetrievalRoute } from "../runtime/context/retrieval-route-policy.ts";
|
|
150
|
-
import { evaluateRuntimeSolver } from "../runtime/solver/runtime-features.ts";
|
|
151
|
+
import { evaluateRuntimeSolver, refreshGitShape } from "../runtime/solver/runtime-features.ts";
|
|
151
152
|
import { SolverShadowRuntime, solverModeFromEnvironment } from "../runtime/solver/solver-shadow.ts";
|
|
152
153
|
import { TrajectoryRuntime } from "../runtime/trajectory/trajectory-runtime.ts";
|
|
153
154
|
import { parseApplyPatchTargets, registerApplyPatchTool } from "../runtime/tools/apply-patch-tool.ts";
|
|
@@ -206,6 +207,7 @@ import type {
|
|
|
206
207
|
ResolvedPermissionProfile,
|
|
207
208
|
ReviewLens,
|
|
208
209
|
RuntimePolicySettings,
|
|
210
|
+
CommandRule,
|
|
209
211
|
TaskContract,
|
|
210
212
|
TechContextSnapshot,
|
|
211
213
|
TechOption,
|
|
@@ -554,8 +556,6 @@ function verifyProjectCapabilityState(
|
|
|
554
556
|
}
|
|
555
557
|
}
|
|
556
558
|
|
|
557
|
-
// Exec-policy prompts a full-access session does not show: local, reversible.
|
|
558
|
-
const FULL_ACCESS_SKIPPED_PROMPTS = ["prompt-git-add-broad"];
|
|
559
559
|
|
|
560
560
|
function evaluateExecPolicy(command: string, profile: ProjectProfile, policy: BasePolicy): {
|
|
561
561
|
mode: RuntimePolicySettings["execPolicy"];
|
|
@@ -564,7 +564,7 @@ function evaluateExecPolicy(command: string, profile: ProjectProfile, policy: Ba
|
|
|
564
564
|
segments: Array<{ command: string; words: string[]; matches: string[]; warnings: string[] }>;
|
|
565
565
|
} {
|
|
566
566
|
const runtime = resolveRuntimePolicy(profile);
|
|
567
|
-
const execPolicy = execPolicyConfig(policy);
|
|
567
|
+
const execPolicy = { ...execPolicyConfig(policy), rules: [...execPolicyConfig(policy).rules, ...autoDenyRules(profile)] };
|
|
568
568
|
const mode = runtime.execPolicy === "off" ? "off" : runtime.execPolicy ?? execPolicy.defaultMode;
|
|
569
569
|
return evaluateExecPolicyCore(command, { policy: { ...policy, execPolicy }, mode }) as {
|
|
570
570
|
mode: RuntimePolicySettings["execPolicy"];
|
|
@@ -923,6 +923,36 @@ function approvalUnavailableFallback(ctx: Pick<ExtensionContext, "ui">): Approva
|
|
|
923
923
|
}
|
|
924
924
|
}
|
|
925
925
|
|
|
926
|
+
// A project's own refusals (profile `autoDeny`) as exec-policy forbid rules.
|
|
927
|
+
function autoDenyRules(profile: ProjectProfile): CommandRule[] {
|
|
928
|
+
return (Array.isArray(profile.autoDeny) ? profile.autoDeny : []).filter((rule) => typeof rule?.pattern === "string" && rule.pattern.trim())
|
|
929
|
+
.map((rule, index) => ({ id: `auto-deny-${index + 1}`, action: "forbid" as const, match: rule.match ?? "contains", value: rule.pattern,
|
|
930
|
+
reason: String(rule.reason ?? "Refused by this project's autoDeny rule"), terminate: rule.terminate === true }));
|
|
931
|
+
}
|
|
932
|
+
// The block for a refused command; a rule with terminate also ends the turn.
|
|
933
|
+
function execRefusal(decision: { reasons: string[]; segments: Array<{ matches: string[] }> }): { block: true; reason: string; terminate?: boolean } {
|
|
934
|
+
const terminate = decision.segments.some((segment) => segment.matches.some((match) => match.startsWith("terminate:")));
|
|
935
|
+
return { block: true, reason: decision.reasons.join("; ") + (terminate ? " Do not retry; the turn ends here." : ""), ...(terminate ? { terminate: true } : {}) };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// The member's four answers in the terminal (Pi's TUI). Elsewhere the
|
|
939
|
+
// terminal keeps its yes/no question and the dashboard card offers the four.
|
|
940
|
+
const TERMINAL_CHOICES: Record<ApprovalChoice, string> = { once: "Allow once", session: "Allow this exact action for the rest of the session",
|
|
941
|
+
deny: "Deny", stop: "Deny and stop the turn" };
|
|
942
|
+
function terminalChooser(ctx: ExtensionContext, title: string, prompt: string, grantable: boolean): (() => Promise<ApprovalChoice>) | undefined {
|
|
943
|
+
if (ctx.mode !== "tui" || !ctx.hasUI || typeof ctx.ui.select !== "function") return undefined;
|
|
944
|
+
const choices = (["once", ...(grantable ? ["session"] : []), "deny", "stop"] as ApprovalChoice[]);
|
|
945
|
+
return async () => {
|
|
946
|
+
const picked = await ctx.ui.select(`${title}\n\n${prompt}`, choices.map((choice) => TERMINAL_CHOICES[choice]));
|
|
947
|
+
return choices.find((choice) => TERMINAL_CHOICES[choice] === picked) ?? "deny";
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Actions the member allowed for the rest of a session: the same tool with
|
|
952
|
+
// the same exact command or input. A handoff is never kept.
|
|
953
|
+
const sessionGrants = new Map<string, Set<string>>();
|
|
954
|
+
const grantKey = (toolName: string, command: string | null, input: unknown) => `${toolName}\u0000${command ?? JSON.stringify(input ?? {})}`;
|
|
955
|
+
|
|
926
956
|
function evaluatePathLikeToolAccess(
|
|
927
957
|
cwd: string,
|
|
928
958
|
toolName: string,
|
|
@@ -1124,10 +1154,13 @@ function normalizePermissionProfileMode(value: unknown): PermissionProfileMode |
|
|
|
1124
1154
|
}
|
|
1125
1155
|
|
|
1126
1156
|
function runtimeEquivalentForPermissionProfile(mode: PermissionProfileMode): string {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
return "sandbox_mode=
|
|
1157
|
+
// What the mode resembles in common sandbox terms. A personal session runs on this
|
|
1158
|
+
// machine with no OS sandbox (execution-backend.js "host"): Piagent's policy
|
|
1159
|
+
// checks are the boundary.
|
|
1160
|
+
if (mode === "read-only") return "like sandbox_mode=read-only, enforced by Piagent policy (no OS sandbox)";
|
|
1161
|
+
if (mode === "trusted-full-access") return "like sandbox_mode=danger-full-access; protected paths, destructive steps and the handoff (push, PR, publish, deploy, remote tools) are still asked";
|
|
1162
|
+
if (mode === "bypass") return "like sandbox_mode=danger-full-access + approval_policy=never for the work; the handoff (push, PR, publish, deploy, remote tools, MCP writes) is still asked, and output redaction and turn checkpoints remain";
|
|
1163
|
+
return "like sandbox_mode=workspace-write + approval_policy=on-request, enforced by Piagent policy (no OS sandbox)";
|
|
1131
1164
|
}
|
|
1132
1165
|
|
|
1133
1166
|
function sessionPermissionOverrideKey(ctx: ExtensionContext): string {
|
|
@@ -3353,6 +3386,11 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3353
3386
|
});
|
|
3354
3387
|
const runtimeSnapshotCapture = new RuntimeSnapshotCapture(), runtimeVersions = readRuntimeVersionMetadata(PLATFORM_ROOT);
|
|
3355
3388
|
const solverShadow = solverMode === "off" ? undefined : new SolverShadowRuntime(solverMode);
|
|
3389
|
+
if (solverShadow) {
|
|
3390
|
+
// The solver's Git shape is read ahead of the prompt (runtime-features.ts).
|
|
3391
|
+
pi.on("session_start", (_event, ctx) => { void refreshGitShape(ctx.cwd); });
|
|
3392
|
+
pi.on("agent_settled", (_event, ctx) => { void refreshGitShape(ctx.cwd); });
|
|
3393
|
+
}
|
|
3356
3394
|
const modelRouteRuntime = new ModelRouteRuntime(parentRoutingMode, routingObjective);
|
|
3357
3395
|
const modelSelectionProvenance = new ModelSelectionProvenanceTracker();
|
|
3358
3396
|
const serviceTierRuntime = new ServiceTierRuntime({ environmentValue: process.env.PIAGENT_FAST_MODE });
|
|
@@ -3717,6 +3755,38 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3717
3755
|
// Bypass is for personal sessions only: a company session keeps Studio's rules.
|
|
3718
3756
|
const bypassActive = (ctx: ExtensionContext) => !isCompanySession(ctx) && ctx.model?.provider !== "agent_watch_managed"
|
|
3719
3757
|
&& resolvePermissionProfile(loadProfileFromContext(ctx), policy, permissionOverrideFromContext(ctx)).mode === "bypass";
|
|
3758
|
+
// Bypass runs the work without asking and stops at the handoff: a push, a
|
|
3759
|
+
// PR or merge, a publish or deploy, a remote or cloud tool, an upload or an
|
|
3760
|
+
// MCP tool that writes outside is asked about, as in every other mode.
|
|
3761
|
+
const bypassHandoff = async (event: { toolName: string; toolCallId?: string; input?: unknown }, ctx: ExtensionContext): Promise<{ block: true; reason: string; terminate?: boolean } | undefined> => {
|
|
3762
|
+
const input = isPlainRecord(event.input) ? event.input : {};
|
|
3763
|
+
const prepared = SHELL_TOOL_NAMES.has(event.toolName) ? null : prepareToolInputForPolicy(event.toolName, input, policy);
|
|
3764
|
+
const shell = extractShellCommandInput(prepared?.proxyShellCarrier && prepared.proxyArgs ? prepared.proxyArgs : SHELL_TOOL_NAMES.has(event.toolName) ? input : {});
|
|
3765
|
+
const command = shell.command ? normalizeShellCommandForPolicy(shell.command) : null;
|
|
3766
|
+
// The project's autoDeny rules hold in Bypass too: they are its own refusals.
|
|
3767
|
+
const projectRules = autoDenyRules(loadProfileFromContext(ctx));
|
|
3768
|
+
if (command && projectRules.length) {
|
|
3769
|
+
const refused = evaluateExecPolicyCore(command, { policy: { execPolicy: { defaultMode: "enforce", bannedPrefixSuggestions: [], rules: projectRules } }, mode: "enforce" });
|
|
3770
|
+
if (refused.decision === "forbid") return execRefusal(refused);
|
|
3771
|
+
}
|
|
3772
|
+
let reason = command ? await handoffReason(command) : null;
|
|
3773
|
+
if (!reason && !command && prepared && classifyExternalAction(event.toolName, prepared.input, policy).decision === "confirm") {
|
|
3774
|
+
const action = await mcpWriteAction(prepared.proxyTool ?? event.toolName);
|
|
3775
|
+
if (action) reason = `${prepared.proxyToolName ?? event.toolName} writes outside this machine (${action})`;
|
|
3776
|
+
}
|
|
3777
|
+
if (!reason) return undefined;
|
|
3778
|
+
const preview = command ?? prepared?.confirmationSummary ?? event.toolName;
|
|
3779
|
+
const approval = await piApprovalBroker.request({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), toolCallId: String(event.toolCallId), expectedTask: null,
|
|
3780
|
+
action: { kind: "external-provider-action", preconditionClass: "runtime-only", treePrecondition: null, toolName: event.toolName, rawAction: { toolName: event.toolName, command, input },
|
|
3781
|
+
commandPreview: command, parameterPreview: preview, targetPaths: [], targetSummaries: [reason], provider: command ? "shell-external" : String(prepared?.proxyToolName ?? event.toolName),
|
|
3782
|
+
urlOrigin: null, requestedScope: "one-handoff", reason: `Bypass still asks before a handoff: ${reason}`, riskClass: "high",
|
|
3783
|
+
allowConsequence: "Run this exact step once; the work leaves this machine.", denyConsequence: "The step does not run; the agent is told you declined." },
|
|
3784
|
+
terminalConfirm: () => ctx.ui.confirm(`Bypass still asks before a handoff.\n\n${reason}\n\n${preview}\n\nAllow?`, "Piagent handoff confirmation"),
|
|
3785
|
+
terminalChoose: terminalChooser(ctx, "Piagent handoff confirmation", `Bypass still asks before a handoff.\n\n${reason}\n\n${preview}`, false), unavailableFallback: approvalUnavailableFallback(ctx) });
|
|
3786
|
+
if (!approval.allowed) return approval.choice === "stop" ? { block: true, terminate: true, reason: `User denied the handoff and stopped the turn: ${reason}. Do not retry; wait for the member.` } : { block: true, reason: `User denied the handoff: ${reason}` };
|
|
3787
|
+
if (!approval.consume()) return { block: true, reason: "Approval became stale before tool start; the action was blocked." };
|
|
3788
|
+
return undefined;
|
|
3789
|
+
};
|
|
3720
3790
|
registerToolCallHook(pi, {
|
|
3721
3791
|
extractLikelyPath: extractLikelyPathFromInput,
|
|
3722
3792
|
redactText,
|
|
@@ -3778,10 +3848,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3778
3848
|
return undefined;
|
|
3779
3849
|
},
|
|
3780
3850
|
authorize: async (event, ctx) => {
|
|
3781
|
-
// Bypass: no Piagent gate asks or blocks
|
|
3782
|
-
//
|
|
3783
|
-
//
|
|
3784
|
-
if (bypassActive(ctx)) return;
|
|
3851
|
+
// Bypass: no Piagent gate asks or blocks the work. The handoff is still
|
|
3852
|
+
// asked (bypassHandoff); tool output is still redacted and the turn still
|
|
3853
|
+
// gets its checkpoint.
|
|
3854
|
+
if (bypassActive(ctx)) return bypassHandoff(event, ctx);
|
|
3785
3855
|
const preInput = isPlainRecord(event.input) ? event.input : {};
|
|
3786
3856
|
const authorizationTargetInspection = authorizationMutationTargetInspection(ctx.cwd, event.toolName, preInput);
|
|
3787
3857
|
if (authorizationTargetInspection.reason) {
|
|
@@ -3872,9 +3942,11 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3872
3942
|
&& !candidate.startsWith("../")
|
|
3873
3943
|
&& !candidate.startsWith(".pi/piagent-state/")
|
|
3874
3944
|
));
|
|
3875
|
-
if (execDecision.mode !== "off" && execDecision.decision === "forbid")
|
|
3876
|
-
|
|
3877
|
-
|
|
3945
|
+
if (execDecision.mode !== "off" && execDecision.decision === "forbid") return execRefusal(execDecision);
|
|
3946
|
+
// The shell syntax tree reads what the scanner may miss: a command in a
|
|
3947
|
+
// function, a case arm or an unquoted heredoc's $( … ).
|
|
3948
|
+
const treeFinding = execDecision.mode !== "off" ? treeExecFinding(command) : null;
|
|
3949
|
+
if (treeFinding?.decision === "forbid") return { block: true, reason: treeFinding.reason };
|
|
3878
3950
|
|
|
3879
3951
|
const protectedHit = findProtectedPathInCommand(command, pathPolicy.shellProtectedPaths);
|
|
3880
3952
|
if (protectedHit) {
|
|
@@ -3903,16 +3975,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3903
3975
|
|
|
3904
3976
|
// Full access skips the prompts for local, reversible steps; deletes,
|
|
3905
3977
|
// migrations and anything sent outside this machine are still asked.
|
|
3906
|
-
const confirmationReasons = (execDecision.mode !== "off" && execDecision.decision === "prompt" ? [...execDecision.reasons] : [])
|
|
3978
|
+
const confirmationReasons = [...(execDecision.mode !== "off" && execDecision.decision === "prompt" ? execDecision.reasons : []), ...(treeFinding?.decision === "prompt" && !execDecision.reasons.includes(treeFinding.reason) ? [treeFinding.reason] : [])]
|
|
3907
3979
|
.filter((reason) => permissionProfile.mode !== "trusted-full-access"
|
|
3908
|
-
|| !
|
|
3909
|
-
|
|
3910
|
-
|
|
3980
|
+
|| ![...FULL_ACCESS_RUNS].some((id) => reason.startsWith(`Prompt required by exec policy ${id}:`)));
|
|
3981
|
+
// The handoff (push, PR, publish, deploy, remote and cloud tools,
|
|
3982
|
+
// uploads) is asked in every mode: runtime/policy/handoff-policy.mjs.
|
|
3983
|
+
const handoff = await handoffReason(command);
|
|
3984
|
+
const shellExternal = findShellExternalConfirmationReason(execDecision.segments, externalActionPolicyConfig(policy));
|
|
3985
|
+
const handoffNote = handoff && !shellExternal && !confirmationReasons.includes(handoff) ? `Handoff requires confirmation: ${handoff}` : undefined;
|
|
3986
|
+
const externalReason = shellExternal ?? (handoff ? handoffNote ?? handoff : undefined);
|
|
3987
|
+
for (const reason of [shellExternal, handoffNote]) if (reason) confirmationReasons.push(reason);
|
|
3911
3988
|
if (confirmationReasons.length > 0) {
|
|
3912
3989
|
pendingHumanApproval = { prompt: `Command requires confirmation.\n\n${confirmationReasons.join("\n")}\n\nAllow?`, title: "Piagent exec policy confirmation",
|
|
3913
3990
|
action: { kind: externalReason ? "external-provider-action" : "workspace-patch", preconditionClass: externalReason ? "runtime-only" : "workspace-tree", toolName: event.toolName, rawAction: { toolName: event.toolName, command, input: preparedInput.input },
|
|
3914
3991
|
commandPreview: command, parameterPreview: preparedInput.confirmationSummary ?? "Shell command", targetPaths: shellMutationTargets, targetSummaries: confirmationReasons, provider: externalReason ? "shell-external" : null, urlOrigin: null,
|
|
3915
|
-
requestedScope: "one-shell-command", reason: confirmationReasons.join("; "), riskClass: shellProjectMutation ? "high" : "medium", allowConsequence: "Run this exact command once, after the guard checks the working tree again.", denyConsequence: "The command does not run; the agent is told you declined." } };
|
|
3992
|
+
requestedScope: handoff ? "one-handoff" : "one-shell-command", reason: confirmationReasons.join("; "), riskClass: shellProjectMutation ? "high" : "medium", allowConsequence: "Run this exact command once, after the guard checks the working tree again.", denyConsequence: "The command does not run; the agent is told you declined." } };
|
|
3916
3993
|
}
|
|
3917
3994
|
}
|
|
3918
3995
|
|
|
@@ -3965,16 +4042,25 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3965
4042
|
: classifiedExternalAction;
|
|
3966
4043
|
if (externalAction.decision === "confirm") {
|
|
3967
4044
|
const inputSummary = preparedInput.confirmationSummary ? `\ninput: ${preparedInput.confirmationSummary}` : "";
|
|
4045
|
+
// A write to the system behind the tool is a handoff: approved one at a time.
|
|
4046
|
+
const writesOut = Boolean(await mcpWriteAction(preparedInput.proxyTool ?? event.toolName));
|
|
3968
4047
|
pendingHumanApproval = { prompt: `External provider action requires confirmation.\n\nprovider: ${externalAction.provider}\naction: ${externalAction.action}\ntool: ${event.toolName}${inputSummary}\n\nAllow?`, title: "Piagent external action confirmation",
|
|
3969
4048
|
action: { kind: "external-provider-action", preconditionClass: "runtime-only", toolName: event.toolName, rawAction: { toolName: event.toolName, input: preparedInput.input, provider: externalAction.provider, action: externalAction.action },
|
|
3970
4049
|
commandPreview: null, parameterPreview: preparedInput.confirmationSummary ?? String(externalAction.action), targetPaths: [], targetSummaries: [], provider: String(externalAction.provider),
|
|
3971
|
-
urlOrigin: null, requestedScope: "one-external-action", reason: `External provider action ${String(externalAction.action)} requires confirmation`, riskClass: "high", allowConsequence: "Run this exact action once, after the guard checks its authority again.", denyConsequence: "The action does not run; nothing outside changes." } };
|
|
4050
|
+
urlOrigin: null, requestedScope: writesOut ? "one-handoff" : "one-external-action", reason: `External provider action ${String(externalAction.action)} requires confirmation`, riskClass: "high", allowConsequence: "Run this exact action once, after the guard checks its authority again.", denyConsequence: "The action does not run; nothing outside changes." } };
|
|
3972
4051
|
}
|
|
3973
4052
|
}
|
|
3974
4053
|
|
|
4054
|
+
const grants = sessionGrants.get(ctx.sessionManager.getSessionId()) ?? new Set<string>();
|
|
4055
|
+
const grantable = Boolean(pendingHumanApproval) && !/handoff/.test(String(pendingHumanApproval!.action.requestedScope));
|
|
4056
|
+
const grant = grantKey(event.toolName, typeof pendingHumanApproval?.action.commandPreview === "string" ? pendingHumanApproval.action.commandPreview : null, preparedInput.input);
|
|
4057
|
+
if (pendingHumanApproval && grantable && grants.has(grant)) pendingHumanApproval = null;
|
|
3975
4058
|
if (pendingHumanApproval) {
|
|
3976
|
-
const approval = await piApprovalBroker.request({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), toolCallId: String(event.toolCallId), expectedTask: null, action: { ...pendingHumanApproval.action, preconditionClass: "runtime-only", treePrecondition: null }, terminalConfirm: () => ctx.ui.confirm(pendingHumanApproval!.prompt, pendingHumanApproval!.title),
|
|
3977
|
-
|
|
4059
|
+
const approval = await piApprovalBroker.request({ cwd: ctx.cwd, rawSessionId: ctx.sessionManager.getSessionId(), toolCallId: String(event.toolCallId), expectedTask: null, action: { ...pendingHumanApproval.action, preconditionClass: "runtime-only", treePrecondition: null }, terminalConfirm: () => ctx.ui.confirm(pendingHumanApproval!.prompt, pendingHumanApproval!.title),
|
|
4060
|
+
terminalChoose: terminalChooser(ctx, pendingHumanApproval.title, pendingHumanApproval.prompt, grantable), unavailableFallback: approvalUnavailableFallback(ctx) });
|
|
4061
|
+
if (!approval.allowed) return { block: true, reason: pendingHumanApproval.action.kind === "external-provider-action" && !SHELL_TOOL_NAMES.has(event.toolName) ? `User denied external provider action: ${event.toolName}` : `User denied command: ${pendingHumanApproval.action.reason}`,
|
|
4062
|
+
...(approval.choice === "stop" ? { terminate: true, reason: `User denied and stopped the turn: ${pendingHumanApproval.action.reason}. Do not retry; wait for the member.` } : {}) };
|
|
4063
|
+
if (approval.choice === "session" && grantable) sessionGrants.set(ctx.sessionManager.getSessionId(), grants.add(grant));
|
|
3978
4064
|
// A WebUI allow is provisional until the guard consumes it here. Without
|
|
3979
4065
|
// this the decision request never got its receipt and the approval stayed
|
|
3980
4066
|
// pending, filling the broker until it stopped accepting WebUI decisions.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { excludedFindSelectorValue, findSelectorOption, SEARCH_COMMANDS, SEARCH_FILE_OPTIONS, SEARCH_GLOB_OPTIONS,
|
|
2
|
-
|
|
1
|
+
import { excludedFindSelectorValue, findSelectorOption, SEARCH_COMMANDS, SEARCH_FILE_OPTIONS, SEARCH_GLOB_OPTIONS, SEARCH_PATTERN_OPTIONS, searchSelectorIsExclusion, searchSelectorWithinToken } from "./shell-selector-policy.js";
|
|
2
|
+
import { CONTROL_PREFIXES, gitSubcommandWords } from "./shell-command-words.js";
|
|
3
3
|
|
|
4
4
|
const PATH_REDIRECT_OPERATORS = new Set(["<", ">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>"]);
|
|
5
5
|
const DATA_ONLY_COMMANDS = new Set(["echo", "printf"]);
|
|
@@ -35,7 +35,7 @@ const INLINE_CODE_FLAGS = new Map([
|
|
|
35
35
|
["perl", new Set(["-e", "-E"])],
|
|
36
36
|
["php", new Set(["-r"])]
|
|
37
37
|
]);
|
|
38
|
-
const SIMPLE_WRAPPERS = new Set(["sudo", "nohup", "time", "nice", "ionice", "command"]);
|
|
38
|
+
const SIMPLE_WRAPPERS = new Set(["sudo", "doas", "nohup", "time", "nice", "ionice", "command", "exec"]);
|
|
39
39
|
|
|
40
40
|
function escapeRegex(value) {
|
|
41
41
|
return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
@@ -703,13 +703,13 @@ function isLeadingRedirection(word) {
|
|
|
703
703
|
return /^[0-9]*(?:>>?\|?|<>?|>&|<&)/.test(word);
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
-
function stripWrapper(words) {
|
|
706
|
+
export function stripWrapper(words) {
|
|
707
707
|
let current = [...words];
|
|
708
708
|
let changed = true;
|
|
709
709
|
|
|
710
710
|
while (changed && current.length > 0) {
|
|
711
711
|
changed = false;
|
|
712
|
-
while (current.length > 0 && isAssignment(current[0])) {
|
|
712
|
+
while (current.length > 0 && (isAssignment(current[0]) || CONTROL_PREFIXES.has(current[0]))) {
|
|
713
713
|
current = current.slice(1);
|
|
714
714
|
changed = true;
|
|
715
715
|
}
|
|
@@ -1186,7 +1186,7 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
1186
1186
|
|
|
1187
1187
|
for (const rule of execPolicy.rules) {
|
|
1188
1188
|
if (!commandRuleMatches(rule, text, words)) continue;
|
|
1189
|
-
matches.push(`${rule.action}:${rule.id}`);
|
|
1189
|
+
matches.push(`${rule.action}:${rule.id}`, ...(rule.action === "forbid" && rule.terminate ? [`terminate:${rule.id}`] : []));
|
|
1190
1190
|
if (rule.action === "forbid") reasons.push(`Forbidden by exec policy ${rule.id}: ${rule.reason}`);
|
|
1191
1191
|
if (rule.action === "prompt") reasons.push(`Prompt required by exec policy ${rule.id}: ${rule.reason}`);
|
|
1192
1192
|
}
|
|
@@ -1229,7 +1229,7 @@ export function evaluateExecPolicyCore(command, options) {
|
|
|
1229
1229
|
const confirmationPatternMatches = (pattern, words) => {
|
|
1230
1230
|
const expected = shellWords(String(pattern ?? ""));
|
|
1231
1231
|
if (expected.length === 0) return false;
|
|
1232
|
-
const executableWords = stripWrapper(words);
|
|
1232
|
+
const executableWords = gitSubcommandWords(stripWrapper(words));
|
|
1233
1233
|
if (arrayStartsWith(executableWords, expected)) return true;
|
|
1234
1234
|
if (expected.length !== 1) return false;
|
|
1235
1235
|
const action = expected[0];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The simple commands a shell line runs, read from a real shell syntax tree.
|
|
2
|
+
//
|
|
3
|
+
// policy-core.js splits a line into segments with its own scanner; this reads
|
|
4
|
+
// the same line through a POSIX/Bash parser and walks every place a command
|
|
5
|
+
// can sit: pipelines, && and ||, if/while/for/case bodies, functions, ( … ),
|
|
6
|
+
// { … }, $( … ), backticks and <( … ). `bash -c '…'`, `sh -c` and `eval` are
|
|
7
|
+
// parsed again, so the text they run is read as commands too. A check that
|
|
8
|
+
// reads both lists sees a command whichever reader finds it.
|
|
9
|
+
//
|
|
10
|
+
// Words come back as text. A word built from an expansion the shell decides
|
|
11
|
+
// at run time ($VAR, $(…), `…`) keeps it as written and is flagged dynamic;
|
|
12
|
+
// nothing is evaluated here. A line the parser rejects gives null: callers
|
|
13
|
+
// keep the scanner's answer, which is never weakened by this module.
|
|
14
|
+
import { parse } from "@aliou/sh";
|
|
15
|
+
|
|
16
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "mksh"]);
|
|
17
|
+
const MAX_DEPTH = 8;
|
|
18
|
+
|
|
19
|
+
function wordText(word) {
|
|
20
|
+
let text = "", dynamic = false;
|
|
21
|
+
const visit = (parts) => {
|
|
22
|
+
for (const part of parts ?? []) {
|
|
23
|
+
if (part.type === "Literal" || part.type === "SglQuoted") text += part.value;
|
|
24
|
+
else if (part.type === "DblQuoted") visit(part.parts);
|
|
25
|
+
else if (part.type === "ParamExp") { text += `$${part.param?.value ?? ""}`; dynamic = true; }
|
|
26
|
+
else { text += "$(…)"; dynamic = true; }
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
visit(word?.parts);
|
|
30
|
+
return { text, dynamic };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The $( … ) and ` … ` an unquoted heredoc body runs: the parser keeps the
|
|
34
|
+
// body as text, and the shell expands it.
|
|
35
|
+
function substitutions(text) {
|
|
36
|
+
const found = [];
|
|
37
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
38
|
+
if (text[index] === "`") {
|
|
39
|
+
const close = text.indexOf("`", index + 1);
|
|
40
|
+
if (close < 0) break;
|
|
41
|
+
found.push(text.slice(index + 1, close)); index = close;
|
|
42
|
+
} else if (text.startsWith("$(", index) && text[index + 2] !== "(") {
|
|
43
|
+
let depth = 0, close = -1;
|
|
44
|
+
for (let at = index + 1; at < text.length; at += 1) {
|
|
45
|
+
if (text[at] === "(") depth += 1;
|
|
46
|
+
else if (text[at] === ")" && --depth === 0) { close = at; break; }
|
|
47
|
+
}
|
|
48
|
+
if (close < 0) break;
|
|
49
|
+
found.push(text.slice(index + 2, close)); index = close;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collect(node, found, depth) {
|
|
56
|
+
if (!node || typeof node !== "object") return;
|
|
57
|
+
if (Array.isArray(node)) { for (const item of node) collect(item, found, depth); return; }
|
|
58
|
+
if (node.type === "Redirect" && node.heredoc && node.target?.parts?.every((part) => part.type === "Literal") && depth < MAX_DEPTH) {
|
|
59
|
+
for (const source of substitutions(wordText(node.heredoc).text)) collectSource(source, found, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
if (node.type === "SimpleCommand" && node.words?.length) {
|
|
62
|
+
const words = node.words.map(wordText);
|
|
63
|
+
found.push({ words: words.map((word) => word.text), dynamic: words.some((word) => word.dynamic) });
|
|
64
|
+
const name = String(words[0]?.text ?? "").split("/").pop();
|
|
65
|
+
const inline = SHELLS.has(name) ? words.findIndex((word, index) => index > 0 && /^-[a-z]*c[a-z]*$/.test(word.text)) + 1 : name === "eval" ? 1 : 0;
|
|
66
|
+
if (inline > 0 && depth < MAX_DEPTH) {
|
|
67
|
+
const source = name === "eval" ? words.slice(1).map((word) => word.text).join(" ") : words[inline]?.text;
|
|
68
|
+
if (source) collectSource(source, found, depth + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const [key, value] of Object.entries(node)) {
|
|
72
|
+
if (key !== "pos" && key !== "end" && value && typeof value === "object") collect(value, found, depth);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectSource(source, found, depth) {
|
|
77
|
+
try { collect(parse(String(source), { dialect: "bash" }).ast, found, depth); } catch { /* the scanner still reads it */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// [{ words, dynamic }] for every simple command, or null when the line does not parse.
|
|
81
|
+
export function astCommands(command) {
|
|
82
|
+
const found = [];
|
|
83
|
+
try { collect(parse(String(command), { dialect: "bash" }).ast, found, 0); } catch { return null; }
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// How a simple command's words name what runs, shared by policy-core.js and
|
|
2
|
+
// the handoff policy (runtime/policy/handoff-policy.mjs).
|
|
3
|
+
|
|
4
|
+
// Shell words that open a compound statement: the command follows them.
|
|
5
|
+
// `if true; then rm -rf /; fi` splits into `then rm -rf /`, which runs rm.
|
|
6
|
+
export const CONTROL_PREFIXES = new Set(["!", "{", "(", "if", "then", "do", "elif", "else", "while", "until"]);
|
|
7
|
+
|
|
8
|
+
// Git's global options come before the subcommand: `git -C api push` and
|
|
9
|
+
// `git -c core.hooksPath=x --no-pager push` push as `git push` does. Without
|
|
10
|
+
// this a rule written as "git push" matched only the plain spelling.
|
|
11
|
+
const GIT_OPTIONS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--exec-path", "--list-cmds"]);
|
|
12
|
+
export function gitSubcommandWords(words) {
|
|
13
|
+
if (String(words[0] ?? "").split("/").pop() !== "git") return words;
|
|
14
|
+
let index = 1;
|
|
15
|
+
while (index < words.length && words[index].startsWith("-")) {
|
|
16
|
+
index += GIT_OPTIONS_WITH_VALUE.has(words[index]) ? 2 : 1;
|
|
17
|
+
}
|
|
18
|
+
return ["git", ...words.slice(index)];
|
|
19
|
+
}
|
|
@@ -1,49 +1,55 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { asksOnlyHandoff, broadAccess, effectivePermission, networkConfirmation } from './permission.mjs';
|
|
3
|
+
import { handoffReason } from '../runtime/policy/handoff-policy.mjs';
|
|
3
4
|
import { engineAlive, engineLauncher } from './container-engine.mjs';
|
|
4
5
|
import { MEMBER_ONLY, memberOnlyReason, credentialReason, runHostCommand } from './host-command.mjs';
|
|
5
6
|
import { processTools } from './background-process.mjs';
|
|
6
7
|
import { checkpointsOf } from './checkpoints.mjs';
|
|
7
8
|
import { detachedEmulator, simulatorCommandReason } from './simulator-commands.mjs';
|
|
8
9
|
|
|
9
|
-
// Commands that leave the plain sandbox
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
10
|
+
// Commands that leave the plain sandbox: run_with_network (internet, a local
|
|
11
|
+
// server or browser), run_with_docker when this machine has Docker (the same
|
|
12
|
+
// plus the engine's socket), run_on_host (on the member's machine, outside the
|
|
13
|
+
// sandbox) and run_on_simulator. What each mode asks (permission.mjs):
|
|
14
|
+
// - Ask first: every command.
|
|
15
|
+
// - Full access: run_with_network, run_with_docker and run_on_simulator run,
|
|
16
|
+
// except the handoff and destructive steps (mustConfirm); run_on_host asks
|
|
17
|
+
// every command.
|
|
18
|
+
// - Bypass: all four run, except the handoff (handoffReason: push, PR,
|
|
19
|
+
// publish, deploy, remote and cloud tools, uploads) and, on the host, a
|
|
20
|
+
// command that reaches credentials.
|
|
17
21
|
const PARAMETERS = { type: 'object', properties: { command: { type: 'string', minLength: 1, maxLength: 4000 }, reason: { type: 'string', minLength: 1, maxLength: 300 }, timeout: { type: 'number', minimum: 1, maximum: 1800 } }, required: ['command', 'reason'], additionalProperties: false };
|
|
18
22
|
|
|
19
23
|
const KINDS = {
|
|
20
24
|
network: {
|
|
21
25
|
name: 'run_with_network', label: 'Run with network', scope: 'network-command-once', provider: 'network', riskClass: 'medium',
|
|
22
|
-
description: 'Run one shell command that needs the internet beyond the package registries bash already reaches (a download site or registry the package proxy refuses), or that starts a local server or a browser (end-to-end tests with Playwright\'s Chromium against a server on 127.0.0.1), after the user approves that exact command. Normal bash has no internet; on macOS it reaches servers on this Mac and runs Playwright\'s Chromium against them, except while a local proxy listens. Credentials (.npmrc, SSH keys, Keychain) stay unavailable, so private registries and git push are not possible here.',
|
|
26
|
+
description: 'Run one shell command that needs the internet beyond the package registries bash already reaches (a download site or registry the package proxy refuses), or that starts a local server or a browser (end-to-end tests with Playwright\'s Chromium against a server on 127.0.0.1), after the user approves that exact command (Full access and Bypass run it without asking, but a push, publish, deploy or upload is always asked). Normal bash has no internet; on macOS it reaches servers on this Mac and runs Playwright\'s Chromium against them, except while a local proxy listens. Credentials (.npmrc, SSH keys, Keychain) stay unavailable, so private registries and git push are not possible here.',
|
|
23
27
|
allow: 'Run this exact command once, with internet access; a server it starts accepts connections while it runs.',
|
|
24
28
|
title: 'Chạy lệnh có internet', allowText: 'chạy đúng lệnh này một lần, có internet; server nó mở nhận kết nối trong lúc chạy.',
|
|
25
29
|
confirm: (self, command) => networkConfirmation(self, command),
|
|
26
30
|
},
|
|
27
31
|
docker: {
|
|
28
32
|
name: 'run_with_docker', label: 'Run with Docker', scope: 'docker-command-once', provider: 'docker', riskClass: 'high',
|
|
29
|
-
description: 'Run one shell command that needs the Docker engine of this machine: docker, docker compose (a database or service for tests), docker build, or tests that start containers themselves (Testcontainers), after the user approves that exact command. It has internet access too. Normal bash and run_with_network cannot reach Docker. Mount only folders inside the project. Registry credentials are unavailable: public images pull, private registries and docker push do not. Containers started in the background (-d, compose up -d) keep running after the command: stop them (docker compose down) when the work is done unless the member wants them kept.',
|
|
33
|
+
description: 'Run one shell command that needs the Docker engine of this machine: docker, docker compose (a database or service for tests), docker build, or tests that start containers themselves (Testcontainers), after the user approves that exact command (Full access and Bypass run it without asking, but docker push or login and any other handoff is always asked). It has internet access too. Normal bash and run_with_network cannot reach Docker. Mount only folders inside the project. Registry credentials are unavailable: public images pull, private registries and docker push do not. Containers started in the background (-d, compose up -d) keep running after the command: stop them (docker compose down) when the work is done unless the member wants them kept.',
|
|
30
34
|
allow: 'Run this exact command once, with internet access and the Docker engine; a container it starts can read folders Docker shares from this machine and may keep running.',
|
|
31
35
|
title: 'Chạy lệnh dùng Docker', allowText: 'chạy đúng lệnh này một lần, có internet và Docker; container nó tạo có thể đọc thư mục Docker được chia sẻ trên máy và có thể tiếp tục chạy.',
|
|
32
36
|
confirm: (self, command) => networkConfirmation(self, command),
|
|
33
37
|
},
|
|
34
38
|
host: {
|
|
35
39
|
name: 'run_on_host', label: 'Run on this machine', scope: 'host-command-once', provider: 'host', riskClass: 'high',
|
|
36
|
-
description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, after they approve that exact command. Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull
|
|
40
|
+
description: 'Run one shell command on the member\'s machine, outside the sandbox, as the member, after they approve that exact command (only Bypass runs it without asking, and even there a push, PR, publish, deploy or a command that reaches credentials is asked). Use it when the sandbox blocks what the task needs and no other tool covers it: install a runtime or tool the project needs (brew install openjdk@21, brew install node@22, sdk install java…), git pull with the member\'s sign-in, git push only when the member asked for it, a private registry, a file outside the project. It has the member\'s files, Keychain and sign-ins, so ask only for what the task needs and say why in reason. No input can be typed: commands that need the member\'s password (sudo, a .pkg installer) or an interactive sign-in are refused; ask the member with ask_user for those.',
|
|
37
41
|
allow: 'Run this exact command once on this machine, outside the sandbox, as you: it can read and change your files and use your sign-ins.',
|
|
38
42
|
title: 'Chạy lệnh trên máy (ngoài sandbox)', allowText: 'chạy đúng lệnh này một lần trên máy bạn, ngoài sandbox, bằng quyền của bạn: lệnh đọc/sửa được file và dùng được tài khoản đã đăng nhập trên máy.',
|
|
39
43
|
// Full access does not cover it: the member approves each command (with
|
|
40
|
-
// what Full access would still have asked about). Bypass asks
|
|
41
|
-
// credentials: a prompt injection's "cat ~/.ssh/id_rsa"
|
|
42
|
-
|
|
44
|
+
// what Full access would still have asked about). Bypass asks about the
|
|
45
|
+
// handoff and about credentials: a prompt injection's "cat ~/.ssh/id_rsa"
|
|
46
|
+
// or "git push --force" is never silent.
|
|
47
|
+
confirm: async (self, command) => asksOnlyHandoff(self) ? credentialReason(command) ?? await handoffReason(command) : await networkConfirmation(self, command) ?? 'ask',
|
|
43
48
|
},
|
|
44
49
|
// The sandbox reaches neither CoreSimulator nor adb. Simulator and app build
|
|
45
50
|
// commands only (simulator-commands.mjs), asked about as network commands:
|
|
46
|
-
// Full access and Bypass run them, except what reaches credentials
|
|
51
|
+
// Full access and Bypass run them, except what reaches credentials and what
|
|
52
|
+
// either mode asks about any command.
|
|
47
53
|
simulator: {
|
|
48
54
|
name: 'run_on_simulator', label: 'Run on simulator', scope: 'simulator-command-once', provider: 'host', riskClass: 'medium',
|
|
49
55
|
description: 'Run one iOS Simulator or Android emulator command on the member\'s machine, outside the sandbox. iOS: xcrun simctl (list, boot, bootstatus -b, install, launch, terminate, openurl, io <device> screenshot <file>, ui appearance, status_bar, spawn <device> log show --info --last 5m --predicate \'process == "<App>"\'), xcodebuild with a simulator destination (build, test: XCUITest is the reliable way to tap, type and answer system dialogs), xcodegen generate, axe (describe-ui reads the screen\'s elements; tap, type, swipe where this Xcode accepts them), open -a Simulator (some Xcode versions run simulators without that window; the device works without it). Android: adb (devices, install, shell am start, shell input tap/text/swipe, exec-out screencap -p > <file>, logcat -d), emulator -avd <name> (started in the background), avdmanager, ./gradlew installDebug. Also flutter, pod install, npx expo/react-native/cap, mkdir -p and cd inside the project. Parts may be joined by && or || and piped into grep, head, tail, xcbeautify or jq; files may be written only inside the project or to /dev/null. Read a screenshot you saved with read to see the screen. Other commands: run_on_host.',
|
|
@@ -60,14 +66,34 @@ async function engineReady(boundary) {
|
|
|
60
66
|
}
|
|
61
67
|
const DOCKER_STOPPED = 'managed-docker-unreachable: Docker engine trên máy không chạy. Gọi start_docker để xin member duyệt mở Docker, rồi chạy lại đúng lệnh này bằng run_with_docker.';
|
|
62
68
|
|
|
63
|
-
//
|
|
69
|
+
// Why a mode that runs commands without asking asks about this one, as the
|
|
70
|
+
// card says it: the mode named as it is.
|
|
71
|
+
export function stillAsks(self, reason) {
|
|
72
|
+
return effectivePermission(self) === 'bypass' ? { en: `Bypass still asks: ${reason}`, vi: `Bypass vẫn hỏi: ${reason}` }
|
|
73
|
+
: { en: `Full access still asks: ${reason}`, vi: `Full access vẫn hỏi: ${reason}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One approval of the member (the dashboard card, else the Terminal). The
|
|
77
|
+
// member may allow it once, allow the same exact action for the rest of the
|
|
78
|
+
// conversation (never a handoff: its scope names it), deny it, or deny it and
|
|
79
|
+
// stop the turn so the agent does not try again.
|
|
80
|
+
const CHOICES = { once: 'Đồng ý một lần', session: 'Đồng ý cho cả cuộc trò chuyện (đúng lệnh này)', deny: 'Từ chối', stop: 'Từ chối và dừng lượt này' };
|
|
64
81
|
export async function approve(self, id, signal, ctx, action, { title, body }) {
|
|
82
|
+
const grantable = !/handoff/.test(String(action.requestedScope ?? '')), key = `${action.toolName}\u0000${action.commandPreview ?? JSON.stringify(action.rawAction ?? {})}`;
|
|
83
|
+
if (grantable && self.sessionApprovals?.has(key)) return;
|
|
65
84
|
const { piApprovalBroker } = await import('../runtime/inspection/approval-broker.ts');
|
|
85
|
+
const choices = ['once', ...(grantable ? ['session'] : []), 'deny', 'stop'];
|
|
66
86
|
const decision = await piApprovalBroker.request({ cwd: self.cwd, rawSessionId: self.session.sessionManager.getSessionId(), toolCallId: id,
|
|
67
87
|
action: { kind: 'external-provider-action', preconditionClass: 'runtime-only', targetPaths: [self.cwd], urlOrigin: null, ...action },
|
|
68
88
|
terminalConfirm: () => ctx?.ui?.confirm?.(title, body) ?? Promise.resolve(false),
|
|
89
|
+
...(ctx?.mode === 'tui' && ctx?.hasUI && typeof ctx.ui?.select === 'function' ? { terminalChoose: async () => {
|
|
90
|
+
const picked = await ctx.ui.select(`${title}\n\n${body}`, choices.map(choice => CHOICES[choice]));
|
|
91
|
+
return choices.find(choice => CHOICES[choice] === picked) ?? 'deny';
|
|
92
|
+
} } : {}),
|
|
69
93
|
unavailableFallback: 'terminal-confirm', recheck: () => !signal?.aborted && Boolean(self.grant) });
|
|
94
|
+
if (!decision.allowed && decision.choice === 'stop') { try { ctx?.abort?.(); } catch { /* the denial below still stands */ } throw Error('managed-operation-denied-stop: bạn đã từ chối và dừng lượt này; không thử lại, chờ member.'); }
|
|
70
95
|
if (!decision.allowed || !decision.consume() || signal?.aborted) throw Error('managed-operation-denied');
|
|
96
|
+
if (decision.choice === 'session' && grantable) (self.sessionApprovals ??= new Set()).add(key);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
function commandTool(self, kind) {
|
|
@@ -80,10 +106,13 @@ function commandTool(self, kind) {
|
|
|
80
106
|
const notSimulator = kind === 'simulator' ? simulatorCommandReason(args.command, self.cwd) : null;
|
|
81
107
|
if (notSimulator) throw Error(`managed-simulator-command: ${notSimulator}.`);
|
|
82
108
|
const confirm = await spec.confirm(self, args.command);
|
|
83
|
-
if (confirm !== null)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
if (confirm !== null) {
|
|
110
|
+
const why = confirm === 'ask' ? null : stillAsks(self, confirm), handoff = await handoffReason(args.command);
|
|
111
|
+
await approve(self, id, signal, ctx, { toolName: spec.name, rawAction: { command: args.command }, commandPreview: String(args.command),
|
|
112
|
+
provider: spec.provider, requestedScope: handoff ? 'handoff-once' : spec.scope, reason: (why ? `${why.en}. ${args.reason}` : String(args.reason)).slice(0, 300), riskClass: spec.riskClass,
|
|
113
|
+
allowConsequence: spec.allow, denyConsequence: 'The command does not run; the agent is told you declined.' },
|
|
114
|
+
{ title: spec.title, body: `${args.command}\n\nLý do: ${args.reason}${why ? `\n${why.vi}` : ''}\n\nĐồng ý: ${spec.allowText}\nTừ chối: lệnh không chạy, agent được báo bạn đã từ chối.` });
|
|
115
|
+
}
|
|
87
116
|
let ok = false;
|
|
88
117
|
if (self.run) await checkpointsOf(self).take();
|
|
89
118
|
const before = await self.changedFiles();
|
|
@@ -101,7 +130,8 @@ function commandTool(self, kind) {
|
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
// start_docker: the engine the member installed, started after they approve
|
|
104
|
-
// (Bypass
|
|
133
|
+
// (Full access and Bypass start it without asking: local and reversible),
|
|
134
|
+
// then waited for.
|
|
105
135
|
const textResult = text => ({ content: [{ type: 'text', text }], details: {} });
|
|
106
136
|
function startDockerTool(self, { wait = 180_000, every = 2000, launcher: found = engineLauncher, spawnImpl = spawn } = {}) {
|
|
107
137
|
return { name: 'start_docker', label: 'Start Docker',
|
|
@@ -134,10 +164,10 @@ export function commandTools(self, options) {
|
|
|
134
164
|
|
|
135
165
|
// What the system prompt says about services and what the sandbox blocks: it
|
|
136
166
|
// is read for every request, so a Docker started since shows up.
|
|
137
|
-
const HOST_PROMPT = ' When the sandbox blocks something the task needs (a missing runtime such as a JDK or a Node version, the member\'s sign-in for git pull or
|
|
167
|
+
const HOST_PROMPT = ' When the sandbox blocks something the task needs (a missing runtime such as a JDK or a Node version, the member\'s sign-in for git pull or a private registry, a file outside the project), do not stop and do not tell the member to run it: request run_on_host with the exact command; it runs on the member\'s machine once they approve. The work ends at the handoff: commit locally, then report what is ready; push, open or merge a pull request, publish or deploy only when the member asked for it in this conversation, and each of those steps is asked about in every access mode. Only what needs the member themselves (their password for sudo or an installer, an interactive sign-in, a window to click) goes to ask_user, with the exact steps and the options to continue or skip; after their answer, check and carry on.';
|
|
138
168
|
export function servicesPrompt(boundary) {
|
|
139
169
|
const simulator = ' The iOS Simulator and Android emulator are not reachable from bash: boot them, build and install the app, launch it, tap and type, and take screenshots with run_on_simulator (save the screenshot in the project, then read it to see the screen). Do not ask the member to run simulator steps you can run.';
|
|
140
|
-
if (boundary.engine) return ' Docker is available through run_with_docker (the member approves each command): a database or service for tests (docker compose up -d, then the tests through bash or run_with_network), docker build, or Testcontainers tests. If the engine stops answering, start_docker asks the member to start it. Stop what you started when done unless the member wants it kept.' + simulator + HOST_PROMPT;
|
|
170
|
+
if (boundary.engine) return ' Docker is available through run_with_docker (in Ask first the member approves each command): a database or service for tests (docker compose up -d, then the tests through bash or run_with_network), docker build, or Testcontainers tests. If the engine stops answering, start_docker asks the member to start it. Stop what you started when done unless the member wants it kept.' + simulator + HOST_PROMPT;
|
|
141
171
|
if (boundary.dockerCli) return ' Docker is installed but not running: when the task needs it, call start_docker (the member approves), then run_with_docker.' + simulator + HOST_PROMPT;
|
|
142
172
|
return ' Docker is not installed on this machine: when the task needs it, ask the member with ask_user (install it, or check another way).' + simulator + HOST_PROMPT;
|
|
143
173
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { asksOnlyHandoff } from './permission.mjs';
|
|
2
2
|
import { approve } from './command-tools.mjs';
|
|
3
3
|
|
|
4
4
|
// web_fetch is a request to any public https address: a page that tells the
|
|
@@ -6,7 +6,7 @@ import { approve } from './command-tools.mjs';
|
|
|
6
6
|
// Documentation and package registries are read freely. Another host is asked
|
|
7
7
|
// about once per conversation; an address there that carries a query or a
|
|
8
8
|
// fragment (where data rides along) is asked about every time. Bypass asks
|
|
9
|
-
// only about the latter. A helper cannot ask the member: it is told to hand
|
|
9
|
+
// only about the latter: data sent out is a handoff. A helper cannot ask the member: it is told to hand
|
|
10
10
|
// the address to the main agent.
|
|
11
11
|
const TRUSTED = ['github.com', 'raw.githubusercontent.com', 'docs.github.com', 'npmjs.com', 'www.npmjs.com', 'registry.npmjs.org', 'pypi.org', 'docs.python.org',
|
|
12
12
|
'developer.mozilla.org', 'nodejs.org', 'go.dev', 'pkg.go.dev', 'crates.io', 'docs.rs', 'doc.rust-lang.org', 'learn.microsoft.com', 'developer.apple.com',
|
|
@@ -21,7 +21,7 @@ export function fetchDecision(owner, role, raw) {
|
|
|
21
21
|
if (trustedHost(host)) return null;
|
|
22
22
|
const carries = Boolean(url.search || url.hash), known = owner.fetchHosts?.has(host);
|
|
23
23
|
if (role !== 'main') return carries || !known ? 'refuse' : null;
|
|
24
|
-
if (
|
|
24
|
+
if (asksOnlyHandoff(owner)) return carries ? 'ask' : null;
|
|
25
25
|
return carries || !known ? 'ask' : null;
|
|
26
26
|
}
|
|
27
27
|
export async function guardFetch(owner, role, raw, id, signal, ctx) {
|