@sema-agent/core 5.18.1 → 5.19.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 +55 -0
- package/dist/core/fs-write-gate-policy.js +1 -1
- package/dist/core/hooks.d.ts +2 -1
- package/dist/core/hooks.js +32 -0
- package/dist/core/runner/active-skill-scope.js +1 -1
- package/dist/core/runner/prepare-task.d.ts +3 -0
- package/dist/core/runner/prepare-task.js +157 -119
- package/dist/core/runner/runtask.js +12 -2
- package/dist/core/runner/session-rule-policy.js +1 -1
- package/dist/core/sensitive-path-policy.js +1 -1
- package/dist/core/tool-policy.d.ts +1 -0
- package/dist/core/tool-policy.js +18 -12
- package/dist/orchestration/run-spec.js +1 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +5 -2
- package/dist/tools/fs/bash-readonly-classifier.js +129 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.19.0 — 2026-08-10
|
|
4
|
+
|
|
5
|
+
> Three bundles, each pre-verified two-way by the evaluation line against the 5.18.1 artifact
|
|
6
|
+
> before this release (criteria [3134], green-lit [3146]).
|
|
7
|
+
|
|
8
|
+
### BREAKING
|
|
9
|
+
|
|
10
|
+
- **A task's PreToolUse screening face now screens its DELEGATED CHILDREN** (issue #33). The
|
|
11
|
+
face used to run only in the gate of the task that installed it, so a call it denied executed
|
|
12
|
+
unrefused one level down — sync, background and workflow legs alike, at any depth. It now
|
|
13
|
+
folds into a ToolPolicy on the existing ancestor-constraint chain (full-installation identity,
|
|
14
|
+
four-axis dedup so a deps-level hook is consulted once per call at any depth). A screening
|
|
15
|
+
`ask` resolves at the installing task's frozen approver; a hook-wired parent with no approver
|
|
16
|
+
denies its children's screened calls fail-closed.
|
|
17
|
+
**Operational**: `parentConstraintCount` gains a screening entry for hook-wired trees — a
|
|
18
|
+
pending approval checkpoint minted before this change and resumed after (or the reverse)
|
|
19
|
+
fails pre-CAS with `resume.parent_constraint_mismatch`. **Drain pending checkpoints across
|
|
20
|
+
the upgrade.** A delegated child of a hook-wired parent also sets
|
|
21
|
+
`requiresParentConstraint`, so its durable resume must re-supply the chain via
|
|
22
|
+
`resumeStream(..., internals)`.
|
|
23
|
+
- **Write guards judge the call's live cwd** (P1, host-lane proven bypass). A resident shell's
|
|
24
|
+
`cd` moved what a relative Write/Edit/NotebookEdit target meant, while the guards kept
|
|
25
|
+
judging the wiring-time root — `cd .git/hooks` + `Write("pre-commit")` landed unrefused.
|
|
26
|
+
Every path-resolving guard (sensitive-path, fs-write-gate, session-rule allowDirs,
|
|
27
|
+
active-skill allowPaths, transcript-integrity, run-spec frozen deny) now resolves the target
|
|
28
|
+
against `ToolCallRequest.cwd`, stamped per call from the same tracked cwd the tools resolve
|
|
29
|
+
with. **The asymmetry is deliberate**: the target follows the live cwd; each guard's own
|
|
30
|
+
configured directories stay anchored to the static root, so a `cd` cannot relocate the fence
|
|
31
|
+
itself. Absent the stamp (direct invocation), every guard falls back to its configured root —
|
|
32
|
+
byte-identical prior behavior. Also newly asked: a non-read-only Bash whose cwd sits inside
|
|
33
|
+
the transcript directory, and a POSIX filename containing a backslash (fail-closed).
|
|
34
|
+
Registered, not fixed: bash builtin writes, hook `touchedPaths`, the shell-operand axis
|
|
35
|
+
(backlog #108 notes).
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Read-only shell classification (`shellGate:"classify"`) prompts less and is more accurate
|
|
40
|
+
in both directions.** The compound splitter carries quote state, so a quoted connector —
|
|
41
|
+
`grep -E "a|b"`, the ordinary spelling of a read-only monitoring pipeline — is argument text
|
|
42
|
+
rather than a phantom command boundary; `2>/dev/null` and output-descriptor fd duplication
|
|
43
|
+
are recognized as moving no data into any file. No widening: every other redirection form
|
|
44
|
+
still rejects, unbalanced quoting falls back to the stricter blind scan, and phantom segments
|
|
45
|
+
could only ever add a rejection. Also tightened in the demoting direction: operand counts now
|
|
46
|
+
match what bash passes, so stdin-reading forms that would hang (`grep "a | b"`,
|
|
47
|
+
`cut -d , -f1`, `grep -if-`, `grep -f/dev/zero`) no longer auto-allow.
|
|
48
|
+
|
|
49
|
+
### Guards / tooling (no behavior surface)
|
|
50
|
+
|
|
51
|
+
- `scripts/ship-post-lint.mjs`: release-post commit references are ancestry-verified against
|
|
52
|
+
the release, and negative claims get the real two-version dist delta to be written against.
|
|
53
|
+
- A brain silent-recovery-arm ratchet freezes the 26 existing tell-nobody arms so a new one has
|
|
54
|
+
to be argued for; the delegation carriage surfaces (live chain, constraint entry, checkpoint
|
|
55
|
+
projection) are exhaustively registered so a new field stops compiling until its travel is
|
|
56
|
+
recorded.
|
|
57
|
+
|
|
3
58
|
## 5.18.1 — 2026-08-10
|
|
4
59
|
|
|
5
60
|
> The 5.18.0 post-release verification round, folded back in. Every surface below was
|
|
@@ -15,7 +15,7 @@ export function createFsWriteGatePolicy(opts) {
|
|
|
15
15
|
if (path === undefined) {
|
|
16
16
|
return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine`);
|
|
17
17
|
}
|
|
18
|
-
const canon = await canonicalizeTarget(env, path, signal, rootPath);
|
|
18
|
+
const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
|
|
19
19
|
if (!canon.ok) {
|
|
20
20
|
return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})`);
|
|
21
21
|
}
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
|
|
2
2
|
import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
|
|
3
|
-
import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "./tool-policy.js";
|
|
3
|
+
import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
|
|
4
4
|
export interface Hooks {
|
|
5
5
|
preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
|
|
6
6
|
postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
|
|
@@ -165,4 +165,5 @@ export interface ToolGateInput {
|
|
|
165
165
|
}) => void;
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
|
+
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
|
|
168
169
|
export declare function runToolGate(input: ToolGateInput): Promise<ToolGateResult>;
|
package/dist/core/hooks.js
CHANGED
|
@@ -98,6 +98,38 @@ function screenPreToolUseResult(r) {
|
|
|
98
98
|
return undefined;
|
|
99
99
|
return refuseOutOfContractDecision(r);
|
|
100
100
|
}
|
|
101
|
+
export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
|
|
102
|
+
return {
|
|
103
|
+
check: async (req) => {
|
|
104
|
+
let r;
|
|
105
|
+
try {
|
|
106
|
+
r = screenPreToolUseResult(await preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, ...(env !== undefined ? { env } : {}) }));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
try {
|
|
110
|
+
onCrash?.(err);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
}
|
|
114
|
+
return { action: "deny", message: preToolUseCrashReason(`this call to "${req.toolName}"`, err), decisionReason: "hook" };
|
|
115
|
+
}
|
|
116
|
+
if (r === undefined)
|
|
117
|
+
return { action: "allow" };
|
|
118
|
+
if (r.action === "deny") {
|
|
119
|
+
return { action: "deny", message: decisionText(r) ?? `tool "${req.toolName}" blocked by a PreToolUse hook`, decisionReason: "hook" };
|
|
120
|
+
}
|
|
121
|
+
if (r.action === "ask") {
|
|
122
|
+
return {
|
|
123
|
+
action: "ask",
|
|
124
|
+
message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`,
|
|
125
|
+
decisionReason: "hook",
|
|
126
|
+
...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return r.updatedInput !== undefined ? { action: "allow", updatedInput: r.updatedInput } : { action: "allow" };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
101
133
|
function traceHookCrash(input, err, notifier) {
|
|
102
134
|
notifier.notify(() => input.onHookError?.(err), "toolGate.onHookError");
|
|
103
135
|
}
|
|
@@ -96,7 +96,7 @@ export function createActiveSkillScopePolicy(opts) {
|
|
|
96
96
|
decisionReason: "safety",
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
-
const canon = await canonicalizeTarget(env, path, signal, rootPath);
|
|
99
|
+
const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
|
|
100
100
|
if (!canon.ok) {
|
|
101
101
|
return {
|
|
102
102
|
action: "deny",
|
|
@@ -10,6 +10,7 @@ import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
|
|
|
10
10
|
import type { OnAsk, ToolPolicy } from "../tool-policy.js";
|
|
11
11
|
import { type ActiveSkillFrame } from "./active-skill-scope.js";
|
|
12
12
|
import type { SessionPermissionRules } from "../session-policy-store.js";
|
|
13
|
+
import { type Hooks } from "../hooks.js";
|
|
13
14
|
import { type RecoveredOrphan } from "../session-reconcile.js";
|
|
14
15
|
import { CacheBreakDetector, type ToolFingerprintInput } from "../cache-break-detector.js";
|
|
15
16
|
import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
|
|
@@ -330,6 +331,8 @@ export interface InheritedGate {
|
|
|
330
331
|
policy: ToolPolicy;
|
|
331
332
|
onAsk?: OnAsk;
|
|
332
333
|
durableMandate?: boolean;
|
|
334
|
+
preToolUse?: Hooks["preToolUse"];
|
|
335
|
+
hookEnv?: unknown;
|
|
333
336
|
}>;
|
|
334
337
|
}
|
|
335
338
|
export declare function isFableFamilyModelId(id: string): boolean;
|
|
@@ -28,7 +28,7 @@ import { CHANGED_FILES_MTIME_EPS_MS, fenceMcpServerInstructions, renderAgentList
|
|
|
28
28
|
import { inlineUntrusted } from "../untrusted-text.js";
|
|
29
29
|
import { emitTrace } from "../trace.js";
|
|
30
30
|
import { createSessionRulePolicy } from "./session-rule-policy.js";
|
|
31
|
-
import { cloneObserverInput, createHookEnvCapabilities, formatHookFeedback, runToolGate } from "../hooks.js";
|
|
31
|
+
import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, runToolGate } from "../hooks.js";
|
|
32
32
|
import { reconcileInterruptedSession } from "../session-reconcile.js";
|
|
33
33
|
import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
|
|
34
34
|
import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
|
|
@@ -1057,6 +1057,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1057
1057
|
const ownOrgVerdictRef = { current: undefined };
|
|
1058
1058
|
const orgAdmissionCheckpointState = () => inheritedAdmittedOrgScopes !== undefined || ownOrgVerdictRef.current !== undefined || orgGovernedProvenance;
|
|
1059
1059
|
const frozenOnAsk = spec.onAsk ?? deps.onAsk;
|
|
1060
|
+
const hookEnvSource = (ownedEnv ?? deps.executionEnv) != null ? executionEnv : undefined;
|
|
1061
|
+
const notifyOwnHookCrash = (err) => {
|
|
1062
|
+
try {
|
|
1063
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
|
|
1064
|
+
}
|
|
1065
|
+
catch {
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1060
1068
|
const frozenOnQuestion = spec.onQuestion ?? deps.onQuestion;
|
|
1061
1069
|
const inheritedGateForChildren = () => {
|
|
1062
1070
|
const ancestorRules = [
|
|
@@ -1066,8 +1074,25 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1066
1074
|
const ownCallerPolicy = lockedPreflight.toolPolicy;
|
|
1067
1075
|
const durableMandate = runtimeCaps?.forceDurableGate === true ||
|
|
1068
1076
|
(spec.durableApproval !== undefined && !isLiveApproverSeat(frozenOnAsk));
|
|
1077
|
+
const ownPreToolUse = hooks?.preToolUse;
|
|
1078
|
+
const hookConstraint = ownPreToolUse !== undefined &&
|
|
1079
|
+
!(inheritedParentConstraints ?? []).some((pc) => pc.preToolUse === ownPreToolUse &&
|
|
1080
|
+
askApproverIdentity(pc.onAsk) === askApproverIdentity(frozenOnAsk) &&
|
|
1081
|
+
(pc.durableMandate === true) === durableMandate &&
|
|
1082
|
+
pc.hookEnv === hookEnvSource)
|
|
1083
|
+
? [
|
|
1084
|
+
{
|
|
1085
|
+
policy: createPreToolUseConstraintPolicy(ownPreToolUse, hookEnvFace, notifyOwnHookCrash),
|
|
1086
|
+
preToolUse: ownPreToolUse,
|
|
1087
|
+
...(hookEnvSource !== undefined ? { hookEnv: hookEnvSource } : {}),
|
|
1088
|
+
...(frozenOnAsk !== undefined ? { onAsk: frozenOnAsk } : {}),
|
|
1089
|
+
...(durableMandate ? { durableMandate: true } : {}),
|
|
1090
|
+
},
|
|
1091
|
+
]
|
|
1092
|
+
: [];
|
|
1069
1093
|
const parentConstraints = [
|
|
1070
1094
|
...(inheritedParentConstraints ?? []),
|
|
1095
|
+
...hookConstraint,
|
|
1071
1096
|
...(ownCallerPolicy !== undefined
|
|
1072
1097
|
? [
|
|
1073
1098
|
{
|
|
@@ -2920,6 +2945,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
2920
2945
|
auditPolicyNames(layer);
|
|
2921
2946
|
const denyNarrowingPolicy = narrowingLayers.length === 0 ? undefined : narrowingLayers.length === 1 ? narrowingLayers[0] : combinePolicies(...narrowingLayers);
|
|
2922
2947
|
const basePolicyForResumeEdit = lockedPreflight.toolPolicy;
|
|
2948
|
+
const hooks = spec.hooks ?? deps.hooks;
|
|
2923
2949
|
const sameInstanceAncestorCount = policy === undefined ? 0 : (inheritedParentConstraints ?? []).reduce((n, pc) => (pc.policy === policy ? n + 1 : n), 0);
|
|
2924
2950
|
const sharedFirstDecision = new Map();
|
|
2925
2951
|
const SHARED_FIRST_DECISION_CAP = 256;
|
|
@@ -3067,125 +3093,133 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
3067
3093
|
editArgs = rr.updatedInput;
|
|
3068
3094
|
}
|
|
3069
3095
|
};
|
|
3070
|
-
const parentConstraintWrappers = (inheritedParentConstraints ?? []).map((pc) =>
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
return first;
|
|
3087
|
-
}
|
|
3088
|
-
if (pc.durableMandate === true) {
|
|
3089
|
-
if (resolveCheckpointStore(spec, deps) !== undefined &&
|
|
3090
|
-
(spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
|
|
3091
|
-
markInheritedUnavailable(creq.toolCallId)) {
|
|
3092
|
-
return first;
|
|
3096
|
+
const parentConstraintWrappers = (inheritedParentConstraints ?? []).map((pc) => pc.preToolUse !== undefined &&
|
|
3097
|
+
pc.preToolUse === hooks?.preToolUse &&
|
|
3098
|
+
pc.durableMandate !== true &&
|
|
3099
|
+
askApproverIdentity(pc.onAsk) === askApproverIdentity(frozenOnAsk) &&
|
|
3100
|
+
pc.hookEnv === hookEnvSource
|
|
3101
|
+
? { check: () => ({ action: "allow" }) }
|
|
3102
|
+
: policy !== undefined && pc.policy === policy
|
|
3103
|
+
? {
|
|
3104
|
+
check: async (creq, csignal) => {
|
|
3105
|
+
const first = sharedFirstDecision.get(creq.toolCallId);
|
|
3106
|
+
if (first === undefined) {
|
|
3107
|
+
return {
|
|
3108
|
+
action: "deny",
|
|
3109
|
+
message: `inherited parent policy could not be arbitrated for "${creq.toolName}" ` +
|
|
3110
|
+
`(shared-instance first decision unavailable); denied fail-closed`,
|
|
3111
|
+
};
|
|
3093
3112
|
}
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
}
|
|
3100
|
-
const presentedArgs = creq.args;
|
|
3101
|
-
const askT0 = now();
|
|
3102
|
-
const resolved = await resolveAsk({
|
|
3103
|
-
toolName: creq.toolName,
|
|
3104
|
-
toolCallId: creq.toolCallId,
|
|
3105
|
-
args: presentedArgs,
|
|
3106
|
-
...ruleSuggestionsOf(creq.toolName, presentedArgs),
|
|
3107
|
-
message: first.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
|
|
3108
|
-
...askSourceIdentity(),
|
|
3109
|
-
...riskAxesOf(creq.toolName),
|
|
3110
|
-
...(first.action === "ask" && first.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
3111
|
-
}, pc.onAsk, csignal ?? abortController.signal);
|
|
3112
|
-
const askWaitMs = Math.max(0, now() - askT0);
|
|
3113
|
-
if (resolved.action === "deny" && resolved.approverUnavailable === true) {
|
|
3114
|
-
if (markInheritedUnavailable(creq.toolCallId))
|
|
3113
|
+
if (first.action === "deny")
|
|
3114
|
+
return first;
|
|
3115
|
+
if (first.action === "allow")
|
|
3116
|
+
return { action: "allow" };
|
|
3117
|
+
if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
|
|
3115
3118
|
return first;
|
|
3116
|
-
return {
|
|
3117
|
-
action: "deny",
|
|
3118
|
-
message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
|
|
3119
|
-
};
|
|
3120
|
-
}
|
|
3121
|
-
if (resolved.action !== "allow")
|
|
3122
|
-
return resolved;
|
|
3123
|
-
if (resolved.updatedInput !== undefined) {
|
|
3124
|
-
return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
|
|
3125
|
-
}
|
|
3126
|
-
recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
|
|
3127
|
-
return { action: "allow" };
|
|
3128
|
-
},
|
|
3129
|
-
}
|
|
3130
|
-
: {
|
|
3131
|
-
check: async (creq, csignal) => {
|
|
3132
|
-
let decision;
|
|
3133
|
-
try {
|
|
3134
|
-
decision = await pc.policy.check(creq, csignal ?? abortController.signal);
|
|
3135
|
-
}
|
|
3136
|
-
catch (err) {
|
|
3137
|
-
return {
|
|
3138
|
-
action: "deny",
|
|
3139
|
-
message: `inherited parent policy errored for "${creq.toolName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
3140
|
-
};
|
|
3141
|
-
}
|
|
3142
|
-
if (decision.action !== "ask")
|
|
3143
|
-
return decision;
|
|
3144
|
-
if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
|
|
3145
|
-
return decision;
|
|
3146
|
-
}
|
|
3147
|
-
if (pc.durableMandate === true) {
|
|
3148
|
-
if (resolveCheckpointStore(spec, deps) !== undefined &&
|
|
3149
|
-
(spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
|
|
3150
|
-
markInheritedUnavailable(creq.toolCallId)) {
|
|
3151
|
-
return decision;
|
|
3152
3119
|
}
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3120
|
+
if (pc.durableMandate === true) {
|
|
3121
|
+
if (resolveCheckpointStore(spec, deps) !== undefined &&
|
|
3122
|
+
(spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
|
|
3123
|
+
markInheritedUnavailable(creq.toolCallId)) {
|
|
3124
|
+
return first;
|
|
3125
|
+
}
|
|
3126
|
+
return {
|
|
3127
|
+
action: "deny",
|
|
3128
|
+
message: `inherited parent policy requires durable approval for "${creq.toolName}" — the parent's durable ` +
|
|
3129
|
+
`ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
const presentedArgs = creq.args;
|
|
3133
|
+
const askT0 = now();
|
|
3134
|
+
const resolved = await resolveAsk({
|
|
3135
|
+
toolName: creq.toolName,
|
|
3136
|
+
toolCallId: creq.toolCallId,
|
|
3137
|
+
args: presentedArgs,
|
|
3138
|
+
...ruleSuggestionsOf(creq.toolName, presentedArgs),
|
|
3139
|
+
message: first.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
|
|
3140
|
+
...askSourceIdentity(),
|
|
3141
|
+
...riskAxesOf(creq.toolName),
|
|
3142
|
+
...(first.action === "ask" && first.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
3143
|
+
}, pc.onAsk, csignal ?? abortController.signal);
|
|
3144
|
+
const askWaitMs = Math.max(0, now() - askT0);
|
|
3145
|
+
if (resolved.action === "deny" && resolved.approverUnavailable === true) {
|
|
3146
|
+
if (markInheritedUnavailable(creq.toolCallId))
|
|
3147
|
+
return first;
|
|
3148
|
+
return {
|
|
3149
|
+
action: "deny",
|
|
3150
|
+
message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
if (resolved.action !== "allow")
|
|
3154
|
+
return resolved;
|
|
3155
|
+
if (resolved.updatedInput !== undefined) {
|
|
3156
|
+
return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
|
|
3157
|
+
}
|
|
3158
|
+
recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
|
|
3159
|
+
return { action: "allow" };
|
|
3160
|
+
},
|
|
3161
|
+
}
|
|
3162
|
+
: {
|
|
3163
|
+
check: async (creq, csignal) => {
|
|
3164
|
+
let decision;
|
|
3165
|
+
try {
|
|
3166
|
+
decision = await pc.policy.check(creq, csignal ?? abortController.signal);
|
|
3167
|
+
}
|
|
3168
|
+
catch (err) {
|
|
3169
|
+
return {
|
|
3170
|
+
action: "deny",
|
|
3171
|
+
message: `inherited parent policy errored for "${creq.toolName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
if (decision.action !== "ask")
|
|
3174
3175
|
return decision;
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3176
|
+
if (creq.toolName === ASK_USER_QUESTION_TOOL_NAME && (pc.durableMandate !== true || markInheritedUnavailable(creq.toolCallId))) {
|
|
3177
|
+
return decision;
|
|
3178
|
+
}
|
|
3179
|
+
if (pc.durableMandate === true) {
|
|
3180
|
+
if (resolveCheckpointStore(spec, deps) !== undefined &&
|
|
3181
|
+
(spec.durableApproval !== undefined || runtimeCaps?.forceDurableGate === true) &&
|
|
3182
|
+
markInheritedUnavailable(creq.toolCallId)) {
|
|
3183
|
+
return decision;
|
|
3184
|
+
}
|
|
3185
|
+
return {
|
|
3186
|
+
action: "deny",
|
|
3187
|
+
message: `inherited parent policy requires durable approval for "${creq.toolName}" — the parent's durable ` +
|
|
3188
|
+
`ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
const presentedArgs = decision.updatedInput !== undefined ? decision.updatedInput : creq.args;
|
|
3192
|
+
const askT0 = now();
|
|
3193
|
+
const resolved = await resolveAsk({
|
|
3194
|
+
toolName: creq.toolName,
|
|
3195
|
+
toolCallId: creq.toolCallId,
|
|
3196
|
+
args: presentedArgs,
|
|
3197
|
+
...ruleSuggestionsOf(creq.toolName, presentedArgs),
|
|
3198
|
+
message: decision.message ?? `approval required for "${creq.toolName}" (inherited parent policy)`,
|
|
3199
|
+
...askSourceIdentity(),
|
|
3200
|
+
...riskAxesOf(creq.toolName),
|
|
3201
|
+
...(decision.action === "ask" && decision.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
3202
|
+
}, pc.onAsk, csignal ?? abortController.signal);
|
|
3203
|
+
const askWaitMs = Math.max(0, now() - askT0);
|
|
3204
|
+
if (resolved.action === "deny" && resolved.approverUnavailable === true) {
|
|
3205
|
+
if (markInheritedUnavailable(creq.toolCallId))
|
|
3206
|
+
return decision;
|
|
3207
|
+
return {
|
|
3208
|
+
action: "deny",
|
|
3209
|
+
message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed`,
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
if (resolved.action !== "allow")
|
|
3213
|
+
return resolved;
|
|
3214
|
+
if (resolved.updatedInput !== undefined) {
|
|
3215
|
+
return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal);
|
|
3216
|
+
}
|
|
3217
|
+
if (pc.preToolUse === undefined) {
|
|
3218
|
+
recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
|
|
3219
|
+
}
|
|
3220
|
+
return decision.updatedInput !== undefined ? { ...resolved, updatedInput: decision.updatedInput } : resolved;
|
|
3221
|
+
},
|
|
3222
|
+
});
|
|
3189
3223
|
const rewriteCapableLayers = policy !== undefined || parentConstraintWrappers.length > 0;
|
|
3190
3224
|
const rewriteEmitterIndex = new Map();
|
|
3191
3225
|
const rewriteCapableChain = [
|
|
@@ -3269,7 +3303,6 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
3269
3303
|
},
|
|
3270
3304
|
}
|
|
3271
3305
|
: foldedPolicy;
|
|
3272
|
-
const hooks = spec.hooks ?? deps.hooks;
|
|
3273
3306
|
const onAsk = spec.onAsk ?? deps.onAsk;
|
|
3274
3307
|
const handWriteTools = handsEnabled && spec.handsReadOnly !== true
|
|
3275
3308
|
? Object.keys(HAND_TOOL_EFFECTS).filter((name) => {
|
|
@@ -3391,7 +3424,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
3391
3424
|
const hookEnvFace = hookContextConsumerWired && (ownedEnv ?? deps.executionEnv) != null ? createHookEnvCapabilities(executionEnv) : undefined;
|
|
3392
3425
|
if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || resourceSuspendEligible || platformSuspendArmed || spec.enablePlanMode === true) {
|
|
3393
3426
|
const adjudicate = effectivePolicy
|
|
3394
|
-
? (req) => raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot }, abortController.signal)), abortController.signal, () => ({
|
|
3427
|
+
? (req) => raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, abortController.signal)), abortController.signal, () => ({
|
|
3395
3428
|
action: "deny",
|
|
3396
3429
|
message: "policy check aborted (task timed out or cancelled)",
|
|
3397
3430
|
}))
|
|
@@ -3918,7 +3951,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
3918
3951
|
if (!presented.ok) {
|
|
3919
3952
|
throw new ParkRefusal(`the stored form of "${req.toolName}"'s arguments could not be presented for re-adjudication (${describeThrown(presented.cause)})`, { cause: presented.cause });
|
|
3920
3953
|
}
|
|
3921
|
-
const reprojected = refuseOutOfContractDecision(await basePolicyForResumeEdit.check({
|
|
3954
|
+
const reprojected = refuseOutOfContractDecision(await basePolicyForResumeEdit.check({
|
|
3955
|
+
toolName: req.toolName,
|
|
3956
|
+
args: presented.value,
|
|
3957
|
+
toolCallId: req.toolCallId,
|
|
3958
|
+
...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}),
|
|
3959
|
+
}, abortController.signal));
|
|
3922
3960
|
const demanded = reprojected.updatedInput !== undefined ? tryCloneArgs(reprojected.updatedInput) : undefined;
|
|
3923
3961
|
const rewroteTheFiledValue = reprojected.updatedInput !== undefined &&
|
|
3924
3962
|
!(demanded?.ok === true &&
|
|
@@ -3853,7 +3853,12 @@ export class Runner {
|
|
|
3853
3853
|
throw new CheckpointError("checkpoint.invalid_outcome", `pending action reached the resolver with decision "${String(outcome.decision)}" — only "allow" executes and only "deny" injects a denial; refusing to execute`);
|
|
3854
3854
|
}
|
|
3855
3855
|
if (outcome.decision === "allow" && outcome.updatedInput !== undefined && prepared.basePolicyForResumeEdit) {
|
|
3856
|
-
const rechecked = refuseOutOfContractDecision(await prepared.basePolicyForResumeEdit.check({
|
|
3856
|
+
const rechecked = refuseOutOfContractDecision(await prepared.basePolicyForResumeEdit.check({
|
|
3857
|
+
toolName: pendingAction.toolName,
|
|
3858
|
+
args: resolvedArgs,
|
|
3859
|
+
toolCallId: pendingAction.toolCallId,
|
|
3860
|
+
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
3861
|
+
}, prepared.abortController.signal));
|
|
3857
3862
|
if (rechecked.action === "deny") {
|
|
3858
3863
|
const editedDenial = formatHookFeedback(`The approver EDITED this call's input; the edited call is denied by the deployment's tool policy and was not executed${rechecked.message ? `: ${rechecked.message}` : ""}.`);
|
|
3859
3864
|
emitEnd(true, { content: editedDenial });
|
|
@@ -3863,7 +3868,12 @@ export class Runner {
|
|
|
3863
3868
|
}
|
|
3864
3869
|
}
|
|
3865
3870
|
if (prepared.denyNarrowingPolicy) {
|
|
3866
|
-
const narrowed = refuseOutOfContractDecision(await prepared.denyNarrowingPolicy.check({
|
|
3871
|
+
const narrowed = refuseOutOfContractDecision(await prepared.denyNarrowingPolicy.check({
|
|
3872
|
+
toolName: pendingAction.toolName,
|
|
3873
|
+
args: resolvedArgs,
|
|
3874
|
+
toolCallId: pendingAction.toolCallId,
|
|
3875
|
+
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
3876
|
+
}, prepared.abortController.signal));
|
|
3867
3877
|
if (narrowed.action === "deny") {
|
|
3868
3878
|
const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by a session rule and was not executed${narrowed.message ? `: ${narrowed.message}` : ""}.`);
|
|
3869
3879
|
emitEnd(true, { content: narrowedDenial });
|
|
@@ -58,7 +58,7 @@ export function createSessionRulePolicy(rules, opts) {
|
|
|
58
58
|
if (typeof path !== "string" || path.length === 0) {
|
|
59
59
|
return deny(`write tool "${req.toolName}" denied: session rule confines writes to allowDirs but the call has no resolvable path`);
|
|
60
60
|
}
|
|
61
|
-
const canon = await canonicalizeTarget(env, path, signal, rootPath);
|
|
61
|
+
const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
|
|
62
62
|
if (!canon.ok) {
|
|
63
63
|
return deny(`write to "${path}" denied: its real target could not be resolved against the session-rule allowDirs`);
|
|
64
64
|
}
|
|
@@ -64,7 +64,7 @@ export function createSensitivePathPolicy(opts) {
|
|
|
64
64
|
const path = writeTargetPath(canonical, req.args);
|
|
65
65
|
if (typeof path !== "string" || path.length === 0)
|
|
66
66
|
return { action: "allow" };
|
|
67
|
-
const canon = await canonicalizeTarget(opts.env, path, signal, opts.rootPath);
|
|
67
|
+
const canon = await canonicalizeTarget(opts.env, path, signal, req.cwd ?? opts.rootPath);
|
|
68
68
|
if (!canon.ok) {
|
|
69
69
|
if (canon.unresolvedSymlink) {
|
|
70
70
|
return {
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import { join, normalize as normalizePath, posix as posixPath, sep, win32 as winPath } from "node:path";
|
|
3
3
|
import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
|
|
4
4
|
import { boundInputHashOf } from "./canonical-json.js";
|
|
5
5
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
6
6
|
import { parsePermissionRule } from "./permission-rules.js";
|
|
7
|
-
import { writeTargetPath } from "../tools/fs/safety.js";
|
|
7
|
+
import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
|
|
8
8
|
export const APPROVAL_SETTLED_BY_VALUES = ["human", "timeout", "aborted"];
|
|
9
9
|
export function isApprovalSettledBy(v) {
|
|
10
10
|
return typeof v === "string" && APPROVAL_SETTLED_BY_VALUES.includes(v);
|
|
@@ -526,13 +526,16 @@ export function createTranscriptIntegrityPolicy(opts) {
|
|
|
526
526
|
const readAllow = new Set(opts?.readAllow ?? BASH_READONLY_DEFAULT_ALLOW);
|
|
527
527
|
const shellTools = canonicalToolNameSet(opts?.tools);
|
|
528
528
|
const dirSegs = dirs.map((d) => pathSegments(d).map(foldPathCase));
|
|
529
|
-
const inProtectedDir = (p) => {
|
|
530
|
-
const
|
|
529
|
+
const inProtectedDir = (p, liveCwd) => {
|
|
530
|
+
const rawRelative = !isAbsolutePathForm(p);
|
|
531
|
+
const joinInFamily = (base, rel) => isWinFormPath(base) ? winPath.join(base, rel) : posixPath.join(base, rel);
|
|
532
|
+
const abs = liveCwd !== undefined && rawRelative ? lexicalPath(joinInFamily(liveCwd, p), home) : lexicalPath(p, home);
|
|
531
533
|
const segs = pathSegments(abs).map(foldPathCase);
|
|
532
|
-
if (
|
|
533
|
-
return
|
|
534
|
-
|
|
535
|
-
|
|
534
|
+
if (isAbsolutePathForm(abs) && dirSegs.some((d) => d.length <= segs.length && d.every((s, i) => s === segs[i])))
|
|
535
|
+
return true;
|
|
536
|
+
if (!rawRelative)
|
|
537
|
+
return false;
|
|
538
|
+
return dirSegs.some((d) => relativeContinuesDirTail(pathSegments(lexicalPath(p, home)).map(foldPathCase), d));
|
|
536
539
|
};
|
|
537
540
|
const referencesProtectedDir = (cmd) => {
|
|
538
541
|
const hay = foldPathCase(cmd);
|
|
@@ -571,18 +574,21 @@ export function createTranscriptIntegrityPolicy(opts) {
|
|
|
571
574
|
if (typeof command !== "string")
|
|
572
575
|
return ALLOW;
|
|
573
576
|
const cmd = command.normalize("NFC");
|
|
574
|
-
|
|
577
|
+
const cwdInsideProtected = req.cwd !== undefined && inProtectedDir(req.cwd);
|
|
578
|
+
if (!referencesProtectedDir(cmd) && !cwdInsideProtected)
|
|
575
579
|
return ALLOW;
|
|
576
580
|
const parsed = parseLeadingCommandName(command);
|
|
577
581
|
if ("name" in parsed && readAllow.has(parsed.name))
|
|
578
582
|
return ALLOW;
|
|
579
583
|
return askReason("name" in parsed
|
|
580
|
-
? `\`${parsed.name}\` is not a read-only command`
|
|
581
|
-
:
|
|
584
|
+
? `\`${parsed.name}\` is not a read-only command${cwdInsideProtected ? " and the shell's working directory is inside the transcript directory" : ""}`
|
|
585
|
+
: cwdInsideProtected
|
|
586
|
+
? "the shell's working directory is inside the transcript directory and this is not a single read-only command (fail-closed)"
|
|
587
|
+
: "the command references the transcript directory and is not a single read-only command (fail-closed)");
|
|
582
588
|
}
|
|
583
589
|
if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
|
|
584
590
|
const p = writeTargetPath(toolName, req.args);
|
|
585
|
-
if (typeof p === "string" && inProtectedDir(p)) {
|
|
591
|
+
if (typeof p === "string" && inProtectedDir(p, req.cwd)) {
|
|
586
592
|
return askReason(`"${p}" is inside the session-transcript directory`);
|
|
587
593
|
}
|
|
588
594
|
}
|
|
@@ -26,7 +26,7 @@ function frozenDenyPolicy(rootDir, frozenResolved) {
|
|
|
26
26
|
if (typeof raw !== "string" || raw.length === 0)
|
|
27
27
|
return { action: "allow" };
|
|
28
28
|
const absForm = isAbsolutePathForm(raw);
|
|
29
|
-
const abs = pathKey(absForm ? raw : resolve(rootDir, raw));
|
|
29
|
+
const abs = pathKey(absForm ? raw : resolve(req.cwd || rootDir, raw));
|
|
30
30
|
if (frozen.has(abs)) {
|
|
31
31
|
return { action: "deny", message: `${raw} is part of the frozen specification surface (read-only).` };
|
|
32
32
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
export declare const NOT_AUTO_ALLOWED = "\u2014 not auto-allowed";
|
|
2
2
|
export declare const BASH_READONLY_DEFAULT_ALLOW: readonly string[];
|
|
3
|
-
export
|
|
3
|
+
export interface LeadingCommandNameOptions {
|
|
4
|
+
quotedOperatorsAreText?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseLeadingCommandName(command: string, options?: LeadingCommandNameOptions): {
|
|
4
7
|
name: string;
|
|
5
8
|
} | {
|
|
6
9
|
reject: string;
|
|
7
10
|
};
|
|
8
|
-
export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string
|
|
11
|
+
export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>, options?: LeadingCommandNameOptions): string | undefined;
|
|
9
12
|
export interface BashReadonlyRootBoundary {
|
|
10
13
|
roots: readonly string[];
|
|
11
14
|
cwd?: string;
|
|
@@ -5,11 +5,41 @@ export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
|
5
5
|
"grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
|
|
6
6
|
];
|
|
7
7
|
const SHELL_OPERATORS = /[;&|<>$()`\n\r\\]/;
|
|
8
|
-
|
|
8
|
+
function quoteMask(s) {
|
|
9
|
+
const quoted = new Array(s.length).fill(false);
|
|
10
|
+
let open;
|
|
11
|
+
for (let i = 0; i < s.length; i++) {
|
|
12
|
+
const ch = s[i];
|
|
13
|
+
if (open === undefined && (ch === '"' || ch === "'")) {
|
|
14
|
+
open = ch;
|
|
15
|
+
quoted[i] = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (open !== undefined) {
|
|
19
|
+
quoted[i] = true;
|
|
20
|
+
if (open === ch)
|
|
21
|
+
open = undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { quoted, balanced: open === undefined };
|
|
25
|
+
}
|
|
26
|
+
function hasBareShellOperator(s, quotedOperatorsAreText) {
|
|
27
|
+
if (!quotedOperatorsAreText)
|
|
28
|
+
return SHELL_OPERATORS.test(s);
|
|
29
|
+
const mask = quoteMask(s);
|
|
30
|
+
if (!mask.balanced)
|
|
31
|
+
return SHELL_OPERATORS.test(s);
|
|
32
|
+
for (let i = 0; i < s.length; i++) {
|
|
33
|
+
if (!mask.quoted[i] && SHELL_OPERATORS.test(s[i]))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function parseLeadingCommandName(command, options) {
|
|
9
39
|
const trimmed = command.trim();
|
|
10
40
|
if (!trimmed)
|
|
11
41
|
return { reject: "empty command" };
|
|
12
|
-
if (
|
|
42
|
+
if (hasBareShellOperator(trimmed, options?.quotedOperatorsAreText === true)) {
|
|
13
43
|
return {
|
|
14
44
|
reject: "shell operators (pipes, redirects, ;, &&, command substitution, subshells, variable expansion) are not allowed — run a single simple command",
|
|
15
45
|
};
|
|
@@ -23,8 +53,8 @@ export function parseLeadingCommandName(command) {
|
|
|
23
53
|
return { reject: "the command name must be a bare token (no quotes, braces, globs, or ~)" };
|
|
24
54
|
return { name: first };
|
|
25
55
|
}
|
|
26
|
-
export function coarseReadonlyCheck(command, allow) {
|
|
27
|
-
const parsed = parseLeadingCommandName(command);
|
|
56
|
+
export function coarseReadonlyCheck(command, allow, options) {
|
|
57
|
+
const parsed = parseLeadingCommandName(command, options);
|
|
28
58
|
if ("reject" in parsed)
|
|
29
59
|
return parsed.reject;
|
|
30
60
|
if (!allow.has(parsed.name))
|
|
@@ -32,6 +62,35 @@ export function coarseReadonlyCheck(command, allow) {
|
|
|
32
62
|
return undefined;
|
|
33
63
|
}
|
|
34
64
|
const SHELL_SEGMENT_HARD_REJECT = /[<>$()`\n\r\\]/;
|
|
65
|
+
const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
|
|
66
|
+
function stripExemptRedirections(command) {
|
|
67
|
+
if (!command.includes(">"))
|
|
68
|
+
return command;
|
|
69
|
+
const mask = quoteMask(command);
|
|
70
|
+
if (!mask.balanced)
|
|
71
|
+
return command;
|
|
72
|
+
let out = "";
|
|
73
|
+
let word = "";
|
|
74
|
+
let wordQuoted = false;
|
|
75
|
+
const flush = () => {
|
|
76
|
+
out += wordQuoted || !EXEMPT_REDIRECTION.test(word) ? word : "";
|
|
77
|
+
word = "";
|
|
78
|
+
wordQuoted = false;
|
|
79
|
+
};
|
|
80
|
+
for (let i = 0; i < command.length; i++) {
|
|
81
|
+
const ch = command[i];
|
|
82
|
+
if (!mask.quoted[i] && (ch === " " || ch === "\t")) {
|
|
83
|
+
flush();
|
|
84
|
+
out += ch;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (mask.quoted[i])
|
|
88
|
+
wordQuoted = true;
|
|
89
|
+
word += ch;
|
|
90
|
+
}
|
|
91
|
+
flush();
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
35
94
|
function foldQuoteRemovalToken(tok) {
|
|
36
95
|
if (!tok.includes('"') && !tok.includes("'"))
|
|
37
96
|
return tok;
|
|
@@ -77,12 +136,30 @@ function resolveOperandLexically(base, operand, homeDir) {
|
|
|
77
136
|
return normalizeAbsPathLexically(`${base.replace(/[/\\]+$/, "")}/${raw}`);
|
|
78
137
|
}
|
|
79
138
|
function tokenizeSegment(segment) {
|
|
80
|
-
const raw = segment
|
|
81
|
-
.trim()
|
|
82
|
-
.split(/\s+/)
|
|
83
|
-
.filter((t) => t.length > 0);
|
|
139
|
+
const raw = splitWordsQuoteAware(segment);
|
|
84
140
|
return { folded: raw.map(foldQuoteRemovalToken), raw };
|
|
85
141
|
}
|
|
142
|
+
function splitWordsQuoteAware(segment) {
|
|
143
|
+
const s = segment.trim();
|
|
144
|
+
const mask = quoteMask(s);
|
|
145
|
+
if (!mask.balanced)
|
|
146
|
+
return s.split(/\s+/).filter((t) => t.length > 0);
|
|
147
|
+
const out = [];
|
|
148
|
+
let cur = "";
|
|
149
|
+
for (let i = 0; i < s.length; i++) {
|
|
150
|
+
const ch = s[i];
|
|
151
|
+
if (!mask.quoted[i] && /\s/.test(ch)) {
|
|
152
|
+
if (cur.length > 0)
|
|
153
|
+
out.push(cur);
|
|
154
|
+
cur = "";
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
cur += ch;
|
|
158
|
+
}
|
|
159
|
+
if (cur.length > 0)
|
|
160
|
+
out.push(cur);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
86
163
|
function hasUnquotedExpansionMetachar(rawToken) {
|
|
87
164
|
let open;
|
|
88
165
|
for (const ch of rawToken) {
|
|
@@ -147,6 +224,26 @@ function isCutDelimiterPayloadLongOption(tok) {
|
|
|
147
224
|
const name = longOptionNameOf(tok);
|
|
148
225
|
return name !== undefined && (isLongOptionAbbrevOf(name, "delimiter") || isLongOptionAbbrevOf(name, "output-delimiter"));
|
|
149
226
|
}
|
|
227
|
+
function isGrepClusterFileStdin(tok) {
|
|
228
|
+
if (tok.startsWith("--") || !/^-[A-Za-z0-9]/.test(tok))
|
|
229
|
+
return false;
|
|
230
|
+
for (let i = 1; i < tok.length; i++) {
|
|
231
|
+
const ch = tok[i];
|
|
232
|
+
if (ch === "e")
|
|
233
|
+
return false;
|
|
234
|
+
if (ch === "f")
|
|
235
|
+
return tok.slice(i + 1) === "-";
|
|
236
|
+
if (!/[A-Za-z0-9]/.test(ch))
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
function isCutSeparatedDelimiterOption(tok) {
|
|
242
|
+
if (tok.includes("="))
|
|
243
|
+
return false;
|
|
244
|
+
const name = longOptionNameOf(tok);
|
|
245
|
+
return name !== undefined && (isLongOptionAbbrevOf(name, "delimiter") || isLongOptionAbbrevOf(name, "output-delimiter"));
|
|
246
|
+
}
|
|
150
247
|
function isGrepFileStdinLongOption(tok) {
|
|
151
248
|
const eq = tok.indexOf("=");
|
|
152
249
|
if (eq < 0 || tok.slice(eq + 1) !== "-")
|
|
@@ -285,16 +382,22 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
285
382
|
const trimmed = command.trim();
|
|
286
383
|
if (!trimmed)
|
|
287
384
|
return { reason: "empty command" };
|
|
288
|
-
|
|
385
|
+
const redirectionStripped = stripExemptRedirections(trimmed);
|
|
386
|
+
if (SHELL_SEGMENT_HARD_REJECT.test(redirectionStripped)) {
|
|
289
387
|
return { reason: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
|
|
290
388
|
}
|
|
291
|
-
const source =
|
|
389
|
+
const source = redirectionStripped.endsWith(";") ? redirectionStripped.slice(0, -1) : redirectionStripped;
|
|
292
390
|
const segments = [];
|
|
293
391
|
const pipeFed = [false];
|
|
392
|
+
const mask = quoteMask(source);
|
|
393
|
+
const quoted = (i) => mask.balanced && mask.quoted[i] === true;
|
|
294
394
|
let cur = "";
|
|
295
395
|
for (let i = 0; i < source.length; i++) {
|
|
296
396
|
const c = source[i];
|
|
297
|
-
if (
|
|
397
|
+
if (quoted(i)) {
|
|
398
|
+
cur += c;
|
|
399
|
+
}
|
|
400
|
+
else if (c === "&") {
|
|
298
401
|
if (source[i + 1] === "&") {
|
|
299
402
|
segments.push(cur);
|
|
300
403
|
cur = "";
|
|
@@ -327,7 +430,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
327
430
|
}
|
|
328
431
|
segments.push(cur);
|
|
329
432
|
for (const segment of segments) {
|
|
330
|
-
const reason = coarseReadonlyCheck(segment, allow);
|
|
433
|
+
const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
|
|
331
434
|
if (reason !== undefined)
|
|
332
435
|
return { reason };
|
|
333
436
|
}
|
|
@@ -368,21 +471,21 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
368
471
|
continue;
|
|
369
472
|
foldedSegments.push(segmentTokens);
|
|
370
473
|
const name = toks[0];
|
|
371
|
-
const nonOption = toks.slice(1).filter((t) => !t.startsWith("-"));
|
|
372
474
|
if (!(pipeFed[si] ?? false)) {
|
|
373
475
|
const floor = STDIN_FILE_FLOOR[name];
|
|
374
476
|
const restArgs = toks.slice(1);
|
|
375
477
|
const grepPatternSuppliedByFlag = name === "grep" && restArgs.some(isGrepPatternFlagToken);
|
|
376
478
|
let sawNonFlagOperand = false;
|
|
377
479
|
let hasStdinDash = false;
|
|
480
|
+
let operandCount = 0;
|
|
378
481
|
if (name !== "tr") {
|
|
379
482
|
for (let k = 0; k < restArgs.length; k++) {
|
|
380
483
|
const t = restArgs[k];
|
|
381
|
-
if (name === "cut" && (t === "-d" || t
|
|
484
|
+
if (name === "cut" && (t === "-d" || isCutSeparatedDelimiterOption(t))) {
|
|
382
485
|
k++;
|
|
383
486
|
continue;
|
|
384
487
|
}
|
|
385
|
-
if (name === "grep" && (t
|
|
488
|
+
if (name === "grep" && (isGrepClusterFileStdin(t) || isGrepFileStdinLongOption(t))) {
|
|
386
489
|
hasStdinDash = true;
|
|
387
490
|
break;
|
|
388
491
|
}
|
|
@@ -390,6 +493,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
390
493
|
continue;
|
|
391
494
|
const isGrepPatternPosition = name === "grep" && !grepPatternSuppliedByFlag && !sawNonFlagOperand;
|
|
392
495
|
sawNonFlagOperand = true;
|
|
496
|
+
operandCount++;
|
|
393
497
|
if (isGrepPatternPosition)
|
|
394
498
|
continue;
|
|
395
499
|
if (t === "-") {
|
|
@@ -401,7 +505,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
401
505
|
if (floor !== undefined && hasStdinDash) {
|
|
402
506
|
return { reason: `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
|
|
403
507
|
}
|
|
404
|
-
if (floor !== undefined &&
|
|
508
|
+
if (floor !== undefined && operandCount < floor) {
|
|
405
509
|
return { reason: `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
|
|
406
510
|
}
|
|
407
511
|
}
|
|
@@ -409,7 +513,15 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
|
|
|
409
513
|
return { reason: "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED };
|
|
410
514
|
}
|
|
411
515
|
const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
|
|
412
|
-
const
|
|
516
|
+
const deviceCandidates = [];
|
|
517
|
+
for (const t of toks.slice(1)) {
|
|
518
|
+
if (t.startsWith("-") && t !== "-") {
|
|
519
|
+
deviceCandidates.push(...attachedOptionPayloads(t));
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
deviceCandidates.push(t);
|
|
523
|
+
}
|
|
524
|
+
const deviceArgs = deviceCandidates.map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
|
|
413
525
|
const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
|
|
414
526
|
if (!rescuedByHead && deviceArgs.length > 0) {
|
|
415
527
|
return { reason: "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) " + NOT_AUTO_ALLOWED };
|