@sema-agent/core 7.9.0 → 7.9.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 +23 -0
- package/dist/core/ask-origin.d.ts +7 -3
- package/dist/core/checkpoint-store.d.ts +2 -7
- package/dist/core/effective-path-target.d.ts +43 -0
- package/dist/core/effective-path-target.js +56 -0
- package/dist/core/engine-notice.d.ts +8 -0
- package/dist/core/fs-write-gate-policy.js +2 -1
- package/dist/core/gate-lanes.d.ts +1 -0
- package/dist/core/gate-lanes.js +11 -5
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.d.ts +10 -1
- package/dist/core/mcp-injection-drop.d.ts +74 -0
- package/dist/core/mcp-injection-drop.js +27 -0
- package/dist/core/permission-rule-model.d.ts +91 -75
- package/dist/core/permission-rule-model.js +90 -111
- package/dist/core/permission-rule-org.d.ts +12 -6
- package/dist/core/permission-rule-org.js +9 -3
- package/dist/core/permission-rules.d.ts +3 -2
- package/dist/core/permission-rules.js +38 -15
- package/dist/core/persisted-rule-arms.d.ts +7 -2
- package/dist/core/persisted-rule-arms.js +3 -1
- package/dist/core/runner/active-skill-scope.js +2 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +12 -4
- package/dist/core/runner/permission-rule-lanes.js +13 -15
- package/dist/core/runner/session-rule-policy.js +2 -1
- package/dist/core/runner/tool-face-overlay.js +22 -3
- package/dist/core/sensitive-path-policy.js +5 -3
- package/dist/core/shell-lexer.d.ts +47 -0
- package/dist/core/shell-lexer.js +478 -0
- package/dist/core/shell-scan.d.ts +60 -0
- package/dist/core/shell-scan.js +183 -0
- package/dist/core/shell-wrapper-table.d.ts +297 -0
- package/dist/core/shell-wrapper-table.js +58 -0
- package/dist/core/tool-catalog-entries.js +6 -6
- package/dist/core/tool-face.d.ts +80 -4
- package/dist/core/tool-face.js +10 -0
- package/dist/core/tool-policy.d.ts +1 -6
- package/dist/core/tool-registry.d.ts +8 -11
- package/dist/core/tool-registry.js +5 -2
- package/dist/core/tool-roster.d.ts +11 -2
- package/dist/core/tool-roster.js +21 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/tools/fs/fs-search-tools.d.ts +3 -2
- package/dist/tools/fs/fs-search-tools.js +17 -9
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/fs/search.d.ts +0 -8
- package/dist/tools/fs/search.js +0 -23
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +57 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
|
|
2
2
|
import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
|
|
3
3
|
import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
|
|
4
|
-
import { isUsablePathBase,
|
|
5
|
-
import {
|
|
4
|
+
import { isUsablePathBase, parseRuleText, pathRuleReaches, programRunReachOf } from "./permission-rule-model.js";
|
|
5
|
+
import { effectivePathTargetOf } from "./effective-path-target.js";
|
|
6
|
+
import { protectivePathTargetOf } from "./tool-registry.js";
|
|
6
7
|
export { parsePermissionRule };
|
|
7
8
|
export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
|
|
8
9
|
const DEFAULT_CAPS = {
|
|
@@ -317,18 +318,34 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
317
318
|
const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
|
|
318
319
|
const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
|
|
319
320
|
const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
|
|
320
|
-
const cwdBase = (pathBases.cwd ?? pathBases.root ?? "").replace(/\/+$/, "");
|
|
321
321
|
const contentHit = (lane, req) => {
|
|
322
322
|
if (lane.length === 0)
|
|
323
323
|
return undefined;
|
|
324
|
-
const
|
|
325
|
-
const spelled = pt !== undefined ? pathTargetValue(req.args, pt) : undefined;
|
|
326
|
-
const target = spelled === undefined ? undefined : lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${cwdBase}/${spelled}`);
|
|
324
|
+
const target = effectivePathTargetOf(req, protectivePathTargetOf(req), pathBases);
|
|
327
325
|
const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
326
|
+
let unreadable;
|
|
327
|
+
for (const r of lane) {
|
|
328
|
+
if (r.parsed.match === "subpath" || r.parsed.match === "path") {
|
|
329
|
+
if (target !== undefined && pathRuleReaches(r.parsed, target, pathBases))
|
|
330
|
+
return { rule: r };
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (typeof command !== "string")
|
|
334
|
+
continue;
|
|
335
|
+
const outcome = programRunReachOf(r.parsed, command);
|
|
336
|
+
if (outcome.reach === "reached")
|
|
337
|
+
return { rule: r };
|
|
338
|
+
if (outcome.reach === "unreadable")
|
|
339
|
+
unreadable ??= { rule: r, unreadable: outcome.reason };
|
|
340
|
+
}
|
|
341
|
+
return unreadable;
|
|
331
342
|
};
|
|
343
|
+
const unreadableAsk = (hit, req) => ({
|
|
344
|
+
action: "ask",
|
|
345
|
+
message: `tool "${req.toolName}" needs approval: the command could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
|
|
346
|
+
matchedAskRule: hit.rule.ruleText,
|
|
347
|
+
requiresRealApproval: true,
|
|
348
|
+
});
|
|
332
349
|
return {
|
|
333
350
|
nameSets: [nameSets],
|
|
334
351
|
check(req) {
|
|
@@ -342,15 +359,19 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
342
359
|
if (coveringDeny)
|
|
343
360
|
return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
|
|
344
361
|
}
|
|
362
|
+
const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
|
|
363
|
+
if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
|
|
364
|
+
return { action: "deny", message: ruleMessage("denied", contentDeny.rule.ruleText, contentDeny.rule.source) };
|
|
365
|
+
}
|
|
345
366
|
if (entry) {
|
|
346
|
-
const contentDeny = contentHit(entry.content.deny, req);
|
|
347
|
-
if (contentDeny) {
|
|
348
|
-
return { action: "deny", message: ruleMessage("denied", contentDeny.ruleText, contentDeny.source) };
|
|
349
|
-
}
|
|
350
367
|
const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
|
|
351
368
|
if (paramDeny) {
|
|
352
369
|
return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
|
|
353
370
|
}
|
|
371
|
+
}
|
|
372
|
+
if (contentDeny?.unreadable !== undefined)
|
|
373
|
+
return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
|
|
374
|
+
if (entry) {
|
|
354
375
|
if (entry.bare.ask) {
|
|
355
376
|
return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
|
|
356
377
|
}
|
|
@@ -362,8 +383,10 @@ export function createPermissionRulePolicy(rules, opts) {
|
|
|
362
383
|
}
|
|
363
384
|
if (entry) {
|
|
364
385
|
const contentAsk = contentHit(entry.content.ask, req);
|
|
365
|
-
if (contentAsk) {
|
|
366
|
-
|
|
386
|
+
if (contentAsk !== undefined) {
|
|
387
|
+
if (contentAsk.unreadable !== undefined)
|
|
388
|
+
return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
|
|
389
|
+
return { action: "ask", message: ruleMessage("flagged", contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
|
|
367
390
|
}
|
|
368
391
|
const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
|
|
369
392
|
if (paramAsk) {
|
|
@@ -18,6 +18,9 @@ import type { PersistedRuleHit, PersistedRuleHitRule } from "./hooks.js";
|
|
|
18
18
|
export interface PersistedRuleRead {
|
|
19
19
|
readonly hit?: PersistedRuleHit;
|
|
20
20
|
readonly unreadable?: true;
|
|
21
|
+
/** The lexer's reason when the CALL could not be read against the person's deny/ask rows (absent for
|
|
22
|
+
* a store read failure) — the message says which it was; the decision is the same. */
|
|
23
|
+
readonly reason?: string;
|
|
21
24
|
}
|
|
22
25
|
/** Display form of a rule set (design/375 §5.1): every rule text is a DISPLAY value on a human trust
|
|
23
26
|
* boundary, and the set must stay readable however large a lane makes it — each member rides
|
|
@@ -42,8 +45,10 @@ export declare function disclosedRuleSet(rules: readonly PersistedRuleHitRule[])
|
|
|
42
45
|
* ask, the marker is stamped if absent and the message names the rule — the standing question
|
|
43
46
|
* outranks a remembered yes across lanes, exactly as the DSL's own order says within one; a deny
|
|
44
47
|
* stays a deny.
|
|
45
|
-
* · UNREADABLE — the wired store could not be read (a read failure, a timeout)
|
|
46
|
-
*
|
|
48
|
+
* · UNREADABLE — the wired store could not be read (a read failure, a timeout), or the CALL could not be
|
|
49
|
+
* read against the person's standing deny/ask rows (`reason` carries the lexer's word: an expansion
|
|
50
|
+
* where a rule reads a word, an unterminated quote, a syntax error): the person's deny/ask rows
|
|
51
|
+
* cannot be enforced for this call, so it fails CLOSED the way an unreadable org snapshot does — an
|
|
47
52
|
* allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
|
|
48
53
|
* derives the origin `rule_store_unavailable` from its own record of the read), an ask acquires the
|
|
49
54
|
* bit, a deny stays a deny. Reading an unreadable store as "no rule" would erase every deny the
|
|
@@ -8,7 +8,9 @@ export function applyPersistedTightening(decision, read) {
|
|
|
8
8
|
if (read?.unreadable === true) {
|
|
9
9
|
if (decision.action === "deny")
|
|
10
10
|
return { decision };
|
|
11
|
-
const message =
|
|
11
|
+
const message = read.reason !== undefined
|
|
12
|
+
? `this call could not be read against the person's standing deny/ask rules (${read.reason}) — whether one of them forbids it is unknown, so it needs a real approval`
|
|
13
|
+
: "the persisted permission-rule store could not be read for this call — the person's own deny/ask rules cannot be enforced, so this call needs a real approval until the store is readable";
|
|
12
14
|
return {
|
|
13
15
|
decision: decision.action === "allow"
|
|
14
16
|
? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canonicalizeTarget } from "../../tools/fs/safety.js";
|
|
2
2
|
import { pathTargetValue } from "../tool-registry.js";
|
|
3
|
+
import { pathTargetBaseOf } from "../effective-path-target.js";
|
|
3
4
|
import { PATH_WRITE_TOOLS, isWithin } from "./session-rule-policy.js";
|
|
4
5
|
import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
|
|
5
6
|
export class ActiveSkillScope {
|
|
@@ -99,7 +100,7 @@ export function createActiveSkillScopePolicy(opts) {
|
|
|
99
100
|
decisionReason: "safety",
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
|
-
const canon = await canonicalizeTarget(env, path, signal, req.cwd
|
|
103
|
+
const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declaredTarget, { root: rootPath, cwd: req.cwd }));
|
|
103
104
|
if (!canon.ok) {
|
|
104
105
|
return {
|
|
105
106
|
action: "deny",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* silence the ungated-write warning for deployments that wired no policy at all.
|
|
27
27
|
*/
|
|
28
28
|
import type { AskRuleEvidence, ToolCallRequest } from "../tool-policy.js";
|
|
29
|
-
import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit } from "../hooks.js";
|
|
29
|
+
import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit, type PersistedRuleUnreadable } from "../hooks.js";
|
|
30
30
|
import type { PersistedRule, PersistedRuleVerdict, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
|
|
31
31
|
import { type OrgRuleResolution } from "../permission-rule-org.js";
|
|
32
32
|
import type { PermissionRuleStoreProvider } from "../permission-rule-provider.js";
|
|
@@ -79,11 +79,19 @@ export declare function orgRevisionEvidenceOf(resolution: OrgRuleResolution, onD
|
|
|
79
79
|
* aliased: the evidence must not change under a store that reuses its row objects.
|
|
80
80
|
*/
|
|
81
81
|
export declare function persistedRuleHitOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | undefined;
|
|
82
|
+
/** The COMMAND arm's projection of a verdict: a call the lexer could not read against a standing deny/ask
|
|
83
|
+
* row is the lane's UNREADABLE answer — the same fail-closed shape an unreadable store takes (a
|
|
84
|
+
* real-approval ask no automatic lane clears), with the lexer's reason in place of the store's — never a
|
|
85
|
+
* matched ask rule, which would let a blanket `onAsk: "allow"` clear a question nobody can answer. Every
|
|
86
|
+
* other verdict is {@link persistedRuleHitOf}'s hit. */
|
|
87
|
+
export declare function persistedRuleAnswerOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | PersistedRuleUnreadable | undefined;
|
|
82
88
|
/**
|
|
83
89
|
* design/382 §2.5-1, widened to every path-targeting tool — the persisted-rule lane's PATH arm: the call's
|
|
84
|
-
* target path in the SAME lexical identity the rule family is defined over
|
|
85
|
-
*
|
|
86
|
-
*
|
|
90
|
+
* target path in the SAME lexical identity the rule family is defined over, resolved by the ONE reader every
|
|
91
|
+
* fence and every tool goes through (`effectivePathTargetOf`), so the lane judges the path the tool will
|
|
92
|
+
* really open. The tool's own declaration decides which base a relative spelling takes and what an ABSENT
|
|
93
|
+
* slot means — the lane no longer guesses `liveCwd ?? root` for every tool, which was wrong for the two
|
|
94
|
+
* search tools in three separate shapes (see effective-path-target.ts). Still zero IO.
|
|
87
95
|
*
|
|
88
96
|
* TWO readings of the call's path slot, one per direction (design/388 B6/B17): the tightening arms judge
|
|
89
97
|
* the PROTECTIVE target (the object face's declaration, the catalog's for the name as the floor — reading
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { persistedRuleMandateOf } from "../hooks.js";
|
|
2
|
-
import { adjudicatePersistedPathRules, adjudicatePersistedRules,
|
|
3
|
-
import {
|
|
2
|
+
import { adjudicatePersistedPathRules, adjudicatePersistedRules, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
|
|
3
|
+
import { effectivePathTargetOf } from "../effective-path-target.js";
|
|
4
|
+
import { declaredPathTargetOf, protectivePathTargetOf } from "../tool-registry.js";
|
|
4
5
|
import { orgRuleVerdictFor } from "../permission-rule-org.js";
|
|
5
6
|
export const PERSISTED_RULE_TOOL = "Bash";
|
|
6
7
|
const HANDOFF_BOUND = 256;
|
|
@@ -24,21 +25,18 @@ export function persistedRuleHitOf(verdict) {
|
|
|
24
25
|
? undefined
|
|
25
26
|
: { behavior: verdict.behavior, rules: verdict.rules.map((r) => ({ rule: r.rule, dots: r.adds.map((a) => ({ actor: a.dot.actor, counter: a.dot.counter })) })) };
|
|
26
27
|
}
|
|
28
|
+
export function persistedRuleAnswerOf(verdict) {
|
|
29
|
+
if (verdict?.unreadable !== undefined)
|
|
30
|
+
return { unreadable: true, reason: verdict.unreadable };
|
|
31
|
+
return persistedRuleHitOf(verdict);
|
|
32
|
+
}
|
|
27
33
|
export function pathRuleLaneAnswer(table, req, ctx) {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
return undefined;
|
|
32
|
-
const spelled = pathTargetValue(req.args, pt);
|
|
33
|
-
if (spelled === undefined)
|
|
34
|
-
return undefined;
|
|
35
|
-
return lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${base}/${spelled}`);
|
|
36
|
-
};
|
|
37
|
-
const tighten = resolve(protectivePathTargetOf(req));
|
|
38
|
-
const allow = resolve(declaredPathTargetOf(req));
|
|
34
|
+
const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) };
|
|
35
|
+
const tighten = effectivePathTargetOf(req, protectivePathTargetOf(req), bases);
|
|
36
|
+
const allow = effectivePathTargetOf(req, declaredPathTargetOf(req), bases);
|
|
39
37
|
if (tighten === undefined && allow === undefined)
|
|
40
38
|
return undefined;
|
|
41
|
-
return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) },
|
|
39
|
+
return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
|
|
42
40
|
}
|
|
43
41
|
export function createPermissionRuleLanes(cfg) {
|
|
44
42
|
const provider = cfg.provider;
|
|
@@ -137,7 +135,7 @@ export function createPermissionRuleLanes(cfg) {
|
|
|
137
135
|
const execCwd = liveCwd !== undefined ? { execCwd: liveCwd } : {};
|
|
138
136
|
const verdict = adjudicatePersistedRules(table, { tool: req.toolName, command, cwd: cfg.root, sessionId: cfg.sessionId, ...execCwd });
|
|
139
137
|
if (verdict !== undefined)
|
|
140
|
-
return
|
|
138
|
+
return persistedRuleAnswerOf(verdict);
|
|
141
139
|
const coverage = segmentCoverageOf(command, { persisted: table }, { tool: req.toolName, cwd: cfg.root, sessionId: cfg.sessionId, ...execCwd });
|
|
142
140
|
return coverage !== undefined ? { segmentCoverage: coverage } : undefined;
|
|
143
141
|
},
|
|
@@ -2,6 +2,7 @@ import { canonicalizeTarget, writeTargetPath } from "../../tools/fs/safety.js";
|
|
|
2
2
|
import { isWinFormPath } from "../../tools/fs/safety.js";
|
|
3
3
|
import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
|
|
4
4
|
import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames, skillScopeWriteToolNames } from "../tool-registry.js";
|
|
5
|
+
import { pathTargetBaseOf } from "../effective-path-target.js";
|
|
5
6
|
export const PATH_WRITE_TOOLS = skillScopeWriteToolNames();
|
|
6
7
|
export const PATH_CONFINABLE_WRITE_TOOLS = pathConfinableWriteToolNames();
|
|
7
8
|
export function isWithin(root, p) {
|
|
@@ -65,7 +66,7 @@ export function createSessionRulePolicy(rules, opts) {
|
|
|
65
66
|
if (typeof path !== "string" || path.length === 0) {
|
|
66
67
|
return deny(`write tool "${req.toolName}" denied: session rule confines writes to allowDirs but the call has no resolvable path`);
|
|
67
68
|
}
|
|
68
|
-
const canon = await canonicalizeTarget(env, path, signal, req.cwd
|
|
69
|
+
const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
|
|
69
70
|
if (!canon.ok) {
|
|
70
71
|
return deny(`write to "${path}" denied: its real target could not be resolved against the session-rule allowDirs`);
|
|
71
72
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MCP_NAMESPACE } from "../protocol-table.js";
|
|
2
2
|
import { mintNamespacePrefix } from "../protocol-naming.js";
|
|
3
|
-
import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS } from "../tool-face.js";
|
|
3
|
+
import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, TOOL_PATH_ABSENCES, TOOL_PATH_ACCESSES, TOOL_PATH_BASES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS, } from "../tool-face.js";
|
|
4
4
|
import { inputKeysOf } from "../tool-roster.js";
|
|
5
5
|
import { deliverEngineNotice } from "../engine-notice.js";
|
|
6
6
|
export const APPROVAL_CARD_REQUIRED_KEYS = Object.freeze({
|
|
@@ -26,9 +26,28 @@ export function toolFaceProblem(face, schema) {
|
|
|
26
26
|
if (face.pathTarget !== undefined) {
|
|
27
27
|
if (typeof face.pathTarget.param !== "string" || !top.has(face.pathTarget.param))
|
|
28
28
|
return `pathTarget.param ${JSON.stringify(face.pathTarget.param)} is not a top-level property of the tool's schema`;
|
|
29
|
-
if (!
|
|
29
|
+
if (!TOOL_PATH_ACCESSES.includes(face.pathTarget.access))
|
|
30
30
|
return `pathTarget.access ${JSON.stringify(face.pathTarget.access)} is outside the closed set`;
|
|
31
|
-
const
|
|
31
|
+
const base = face.pathTarget.base;
|
|
32
|
+
if (base !== undefined && !TOOL_PATH_BASES.includes(base))
|
|
33
|
+
return `pathTarget.base ${JSON.stringify(base)} is outside the closed set`;
|
|
34
|
+
if (base === "root" && face.pathTarget.access !== "read")
|
|
35
|
+
return `pathTarget.base "root" needs pathTarget.access "read" — two write guards (the session-transcript directory guard and the frozen-spec deny) resolve a write target against the call's working directory only, so a write face declaring a root base would be judged on two different files (#638)`;
|
|
36
|
+
const absent = face.pathTarget.absent;
|
|
37
|
+
if (absent !== undefined && !TOOL_PATH_ABSENCES.includes(absent))
|
|
38
|
+
return `pathTarget.absent ${JSON.stringify(absent)} is outside the closed set`;
|
|
39
|
+
if (absent === "base" && face.pathTarget.access !== "read")
|
|
40
|
+
return `pathTarget.absent "base" needs pathTarget.access "read" — it says the call is about the whole base directory when the slot is empty, which is a search SCOPE; a write with no path names no file, and the containment fences judge a write on its own target`;
|
|
41
|
+
const patternParam = face.pathTarget.patternParam;
|
|
42
|
+
if (patternParam !== undefined) {
|
|
43
|
+
if (typeof patternParam !== "string" || !top.has(patternParam))
|
|
44
|
+
return `pathTarget.patternParam ${JSON.stringify(patternParam)} is not a top-level property of the tool's schema`;
|
|
45
|
+
if (absent !== "base")
|
|
46
|
+
return `pathTarget.patternParam ${JSON.stringify(patternParam)} is declared but pathTarget.absent is ${JSON.stringify(absent ?? "none")} — the pattern base is only ever read for an absent slot, so this declaration would be inert`;
|
|
47
|
+
}
|
|
48
|
+
const long = overlong("pathTarget.param", [face.pathTarget.param]) ??
|
|
49
|
+
overlong("pathTarget.aliases", face.pathTarget.aliases ?? []) ??
|
|
50
|
+
overlong("pathTarget.patternParam", patternParam === undefined ? [] : [patternParam]);
|
|
32
51
|
if (long !== undefined)
|
|
33
52
|
return long;
|
|
34
53
|
vocabulary.add(face.pathTarget.param);
|
|
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { canonicalizeTarget, expandHomeTilde, isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
|
|
4
4
|
import { compileSegmentPattern, matchSegmentPatterns } from "../tools/fs/read-deny.js";
|
|
5
|
-
import { isProtectedWrite } from "./tool-registry.js";
|
|
5
|
+
import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
|
|
6
|
+
import { pathTargetBaseOf } from "./effective-path-target.js";
|
|
6
7
|
export const RECOMMENDED_SENSITIVE_PATTERNS = [
|
|
7
8
|
".env",
|
|
8
9
|
".env.*",
|
|
@@ -97,10 +98,11 @@ export function createSensitivePathPolicy(opts) {
|
|
|
97
98
|
return { action: "allow" };
|
|
98
99
|
if (guardedByName !== undefined ? !guardedByName.has(req.toolName) : !isProtectedWrite(req))
|
|
99
100
|
return { action: "allow" };
|
|
100
|
-
const
|
|
101
|
+
const protective = protectivePathTargetOf(req);
|
|
102
|
+
const path = writeTargetPath(req, protective);
|
|
101
103
|
if (typeof path !== "string" || path.length === 0)
|
|
102
104
|
return { action: "allow" };
|
|
103
|
-
const canon = await canonicalizeTarget(opts.env, path, signal,
|
|
105
|
+
const canon = await canonicalizeTarget(opts.env, path, signal, pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }));
|
|
104
106
|
if (!canon.ok) {
|
|
105
107
|
if (canon.unresolvedSymlink) {
|
|
106
108
|
return {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** One argv word as the lexer read it. */
|
|
2
|
+
export interface ShellWord {
|
|
3
|
+
/** The word's value with quotes and escapes removed — meaningful only when `expands` is `false`. */
|
|
4
|
+
readonly text: string;
|
|
5
|
+
/** The unquoted source spelling (assignment and wrapper-flag tests read this, never `text`). */
|
|
6
|
+
readonly raw: string;
|
|
7
|
+
/** `false`: a literal word. `"one"`: carries an expansion but yields exactly ONE word (it was double-quoted).
|
|
8
|
+
* `"many"`: an unquoted expansion, glob or brace pattern, or a quoted `"$@"` / array expansion — may
|
|
9
|
+
* yield zero, one or several words. */
|
|
10
|
+
readonly expands: false | "one" | "many";
|
|
11
|
+
}
|
|
12
|
+
/** One program run the lexer found, after keyword stripping. */
|
|
13
|
+
export interface ShellSegment {
|
|
14
|
+
/** The run as SPELLED (a leading keyword removed): the first candidate a rule is compared against. */
|
|
15
|
+
readonly argv: readonly ShellWord[];
|
|
16
|
+
/** The deeper candidates, one per peeled layer (leading assignments, then each wrapper of the table):
|
|
17
|
+
* `sudo -u root rm -r x` ⇒ `[[rm, -r, x]]`. Empty when nothing peeled. */
|
|
18
|
+
readonly peeled: readonly (readonly ShellWord[])[];
|
|
19
|
+
/** Present when this segment's program runs cannot be known from the text at all; `argv` then holds
|
|
20
|
+
* whatever was read before the failure and must not be matched as a run. */
|
|
21
|
+
readonly unreadable?: string;
|
|
22
|
+
/** Present when the segment's program runs are NOT fully known although its candidates could be read:
|
|
23
|
+
* a peel that stopped before the wrapped program could be named (its options carry an expansion, a
|
|
24
|
+
* command string, an option of unknown arity), or a command/process substitution anywhere in the
|
|
25
|
+
* segment (it runs a program the lexer does not read). The candidates stand — a rule they reach is
|
|
26
|
+
* reached — and every other rule reads `unreadable`. */
|
|
27
|
+
readonly peelUnreadable?: string;
|
|
28
|
+
}
|
|
29
|
+
/** A command line as the tightening reader sees it. */
|
|
30
|
+
export interface ShellCommandShape {
|
|
31
|
+
readonly segments: readonly ShellSegment[];
|
|
32
|
+
/** The connector BETWEEN each consecutive pair of segments (`;` `&&` `||` `|` `|&` `&` `\n`). */
|
|
33
|
+
readonly connectors: readonly string[];
|
|
34
|
+
/** A subshell, group, control-structure keyword or dropped empty piece bounded the segments: the
|
|
35
|
+
* connector list is not one flat chain a compound rule could spell. */
|
|
36
|
+
readonly grouped: boolean;
|
|
37
|
+
}
|
|
38
|
+
export { SHELL_WRAPPER_TABLE, type ShellWrapperName } from "./shell-wrapper-table.js";
|
|
39
|
+
/** Longer than this and the command is not read at all (one unreadable segment): every pass is linear,
|
|
40
|
+
* but a substitution nested inside quoting re-enters the quote reader once per nesting level, and the
|
|
41
|
+
* cap is what bounds that on model-supplied text. Upstream's own reader stops at the same size. */
|
|
42
|
+
export declare const MAX_SHELL_READ_CHARS = 10000;
|
|
43
|
+
/** Read `source` as the tightening reader must: every program run the text could perform, or why it cannot be read. */
|
|
44
|
+
export declare function readShellCommand(source: string): ShellCommandShape;
|
|
45
|
+
/** Every program run of `shape` can be NAMED from the text: no segment is unreadable, no peel stopped,
|
|
46
|
+
* and every run candidate's program word is literal. The offer/mint side's "judgeable" question. */
|
|
47
|
+
export declare function isFullyReadable(shape: ShellCommandShape): boolean;
|