@sema-agent/core 7.9.0 → 7.9.2
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 +39 -0
- package/dist/agents/child-model-seat.d.ts +51 -0
- package/dist/agents/child-model-seat.js +34 -0
- package/dist/agents/subagent.d.ts +4 -2
- package/dist/agents/subagent.js +16 -12
- 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/roles.d.ts +8 -0
- package/dist/core/runner/active-skill-scope.js +2 -1
- package/dist/core/runner/contracts.d.ts +3 -2
- 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/prepare-caps-and-workflow.js +2 -2
- package/dist/core/runner/runtask.d.ts +2 -1
- package/dist/core/runner/runtask.js +1 -1
- 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/orchestration/workflow.js +21 -4
- 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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mcp.injection_dropped` — the one mint of the notice a host raises when an entry of the user's own
|
|
3
|
+
* MCP injection (a `.mcp.json` row handed to the engine through the host's request lane) was NOT
|
|
4
|
+
* mounted for the leg. L-167: three drop sites in the host's intake and one cap site each produced
|
|
5
|
+
* their own wording and none reached the user; the engine did not see the entry at all (it never
|
|
6
|
+
* reached `prepareTask`), so no core-side notice could stand for it.
|
|
7
|
+
*
|
|
8
|
+
* Division of labour, the settlement pattern (`settlement.single_mint`): the HOST supplies FACTS
|
|
9
|
+
* (which session, which server name, which of four reasons, optionally which field was bad) and
|
|
10
|
+
* CORE composes the notice — the code word, the audience row, the message text per reason. A host
|
|
11
|
+
* never spells the code or the text itself: a second wording is a second source that drifts, and a
|
|
12
|
+
* host-spelled code is not in {@link import("./governance-codes.js").ENGINE_NOTICE_CODES}'s
|
|
13
|
+
* mint scan, so its audience row could silently rot.
|
|
14
|
+
*
|
|
15
|
+
* Audience `"user"`: the dropped entry is the user's own configuration and the recovery (rename the
|
|
16
|
+
* server, fix the field, trim the list) is in their hands — an operator channel does not reach them.
|
|
17
|
+
* `sessionId` is therefore REQUIRED at the mint (the routing half of a user row: a user disclosure
|
|
18
|
+
* that cannot name its session has nowhere to be delivered) — a host with no session has no user to
|
|
19
|
+
* tell and must not mint. Dedup unit: per leg per server name — the HOST's obligation (it owns the
|
|
20
|
+
* leg's intake loop); this module is pure.
|
|
21
|
+
*/
|
|
22
|
+
import type { AssertAllKeysHandled } from "./ask-origin.js";
|
|
23
|
+
import type { EngineNotice } from "./engine-notice.js";
|
|
24
|
+
/**
|
|
25
|
+
* The closed set of drop reasons ({@link McpInjectionDropFacts.reason}) — the host's intake
|
|
26
|
+
* enumerates exactly these, and a consumer rendering the notice keys on the word:
|
|
27
|
+
* · `malformed_entry` — the entry did not parse as a server declaration (a missing
|
|
28
|
+
* `command`/`url`, a `toolFaces` value of the wrong shape, …);
|
|
29
|
+
* `field` names the offending key when the host knows it;
|
|
30
|
+
* · `name_reserved_by_deployment` — the entry's server name collides with one the deployment
|
|
31
|
+
* itself mounts, and the deployment's wins;
|
|
32
|
+
* · `gate_closed` — the deployment does not accept request-lane MCP injection on
|
|
33
|
+
* this leg at all (a multi-tenant posture): the whole list is
|
|
34
|
+
* ignored, one notice per entry;
|
|
35
|
+
* · `over_cap` — the entry sits beyond the deployment's injection cap.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MCP_INJECTION_DROP_REASONS: readonly ["malformed_entry", "name_reserved_by_deployment", "gate_closed", "over_cap"];
|
|
38
|
+
export type McpInjectionDropReason = (typeof MCP_INJECTION_DROP_REASONS)[number];
|
|
39
|
+
/**
|
|
40
|
+
* The user-facing sentence per reason — the disposition table over {@link MCP_INJECTION_DROP_REASONS}
|
|
41
|
+
* (one reason, one wording; a consumer that wants its own phrasing keys on `detail.reason`, never on
|
|
42
|
+
* this text). Each names the recovery, because the reader is the person who can perform it.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MCP_INJECTION_DROP_TEXT: {
|
|
45
|
+
readonly malformed_entry: "its declaration could not be read — fix the entry and start a new session";
|
|
46
|
+
readonly name_reserved_by_deployment: "the deployment mounts a server of that name and its own wins — rename the entry";
|
|
47
|
+
readonly gate_closed: "this deployment does not accept MCP servers from the request on this leg — ask the operator, or use the deployment's servers";
|
|
48
|
+
readonly over_cap: "the request named more MCP servers than this deployment mounts per leg — trim the list";
|
|
49
|
+
};
|
|
50
|
+
export type DropTextCoversEveryReason = AssertAllKeysHandled<Exclude<McpInjectionDropReason, keyof typeof MCP_INJECTION_DROP_TEXT>>;
|
|
51
|
+
/** What a host knows at the drop site — facts only, no words of the engine's vocabulary composed. */
|
|
52
|
+
export interface McpInjectionDropFacts {
|
|
53
|
+
/** The session whose injection carried the entry — the notice's routing key, required (see module note). */
|
|
54
|
+
readonly sessionId: string;
|
|
55
|
+
/** The entry's server name as the user spelled it (the message quotes it verbatim). */
|
|
56
|
+
readonly server: string;
|
|
57
|
+
readonly reason: McpInjectionDropReason;
|
|
58
|
+
/** `malformed_entry` only, optional: the key the host found bad, so the user knows what to edit. */
|
|
59
|
+
readonly field?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Compose the `mcp.injection_dropped` notice from a host's facts.
|
|
63
|
+
*
|
|
64
|
+
* @contract mcp.injection_dropped.single_mint — the `mcp.injection_dropped` notice is composed by CORE
|
|
65
|
+
* from a host's drop FACTS (session, server name, one of four reasons, optionally the bad field); a
|
|
66
|
+
* host never spells the code, the audience or the sentence, and a fact outside the vocabulary (an
|
|
67
|
+
* unlisted reason word, an empty session or server name) is refused at the mint with a `TypeError`
|
|
68
|
+
* rather than shaped into a notice that would route nowhere or read as an unknown word downstream.
|
|
69
|
+
*
|
|
70
|
+
* `detail: { sessionId, server, reason, field? }` — `sessionId` is lifted onto the typed key by
|
|
71
|
+
* {@link import("./engine-notice.js").deliverEngineNotice}, the one throat, so the two spellings
|
|
72
|
+
* cannot disagree; `field` is present only when the host supplied a non-empty one.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mcpInjectionDroppedNotice(facts: McpInjectionDropFacts): EngineNotice;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const MCP_INJECTION_DROP_REASONS = ["malformed_entry", "name_reserved_by_deployment", "gate_closed", "over_cap"];
|
|
2
|
+
export const MCP_INJECTION_DROP_TEXT = {
|
|
3
|
+
malformed_entry: "its declaration could not be read — fix the entry and start a new session",
|
|
4
|
+
name_reserved_by_deployment: "the deployment mounts a server of that name and its own wins — rename the entry",
|
|
5
|
+
gate_closed: "this deployment does not accept MCP servers from the request on this leg — ask the operator, or use the deployment's servers",
|
|
6
|
+
over_cap: "the request named more MCP servers than this deployment mounts per leg — trim the list",
|
|
7
|
+
};
|
|
8
|
+
const isReason = (x) => typeof x === "string" && MCP_INJECTION_DROP_REASONS.includes(x);
|
|
9
|
+
export function mcpInjectionDroppedNotice(facts) {
|
|
10
|
+
const { sessionId, server, reason, field } = facts;
|
|
11
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
12
|
+
throw new TypeError("mcp.injection_dropped: sessionId is required — a user-audience notice with no session routes nowhere");
|
|
13
|
+
}
|
|
14
|
+
if (typeof server !== "string" || server.length === 0) {
|
|
15
|
+
throw new TypeError("mcp.injection_dropped: server name is required");
|
|
16
|
+
}
|
|
17
|
+
if (!isReason(reason)) {
|
|
18
|
+
throw new TypeError(`mcp.injection_dropped: reason ${JSON.stringify(reason)} is not one of ${MCP_INJECTION_DROP_REASONS.join("/")}`);
|
|
19
|
+
}
|
|
20
|
+
const fieldSeat = typeof field === "string" && field.length > 0 ? { field } : {};
|
|
21
|
+
const where = reason === "malformed_entry" && typeof field === "string" && field.length > 0 ? ` (field \`${field}\`)` : "";
|
|
22
|
+
return {
|
|
23
|
+
code: "mcp.injection_dropped",
|
|
24
|
+
message: `MCP server "${server}" from this request was not mounted: ${MCP_INJECTION_DROP_TEXT[reason]}${where}.`,
|
|
25
|
+
detail: { sessionId, server, reason, ...fieldSeat },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
*
|
|
15
15
|
* The two DIRECTIONS read a command differently, on purpose: an allow must cover the WHOLE call
|
|
16
16
|
* ({@link ruleAdmitsCommand} — one recorded yes clears exactly what it spells), while a deny/ask
|
|
17
|
-
* speaks about any PROGRAM RUN the call may perform ({@link
|
|
18
|
-
* any segment of a chain,
|
|
19
|
-
*
|
|
17
|
+
* speaks about any PROGRAM RUN the call may perform ({@link programRunReachOf} — whole command or
|
|
18
|
+
* any segment of a chain, read with a real shell lexer, `shell-lexer.ts`, and answering `unreadable`
|
|
19
|
+
* for what it cannot read rather than silence). Precedence across behaviors is the upstream one,
|
|
20
|
+
* deny > ask > allow ({@link RULE_BEHAVIOR_PRECEDENCE}, the one table every adjudication reads).
|
|
20
21
|
*
|
|
21
|
-
* ## The floor: what a rule may name, and what a rule may match
|
|
22
|
+
* ## The floor: what a rule may name, and what a rule may match (the ALLOW direction)
|
|
22
23
|
*
|
|
23
24
|
* Everything a shell can use to run a program the text does not NAME — substitution, subshells,
|
|
24
25
|
* backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
|
|
@@ -52,7 +53,9 @@
|
|
|
52
53
|
* The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
|
|
53
54
|
* and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
|
|
54
55
|
* the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
|
|
55
|
-
* on a
|
|
56
|
+
* on a LOOSENING face shows up as a circumvention rather than as a test failure. The tightening
|
|
57
|
+
* direction's lexer is not that second tokenizer: it reads MORE than the floor (never less), and a
|
|
58
|
+
* drift there costs an approval prompt, never a standing approval.
|
|
56
59
|
*
|
|
57
60
|
* argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
|
|
58
61
|
* used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
|
|
@@ -337,18 +340,16 @@ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
|
337
340
|
* Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
|
|
338
341
|
*
|
|
339
342
|
* The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
|
|
340
|
-
* That identification is what every comparison here rests on — the interpreter refusal
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* structure, so a keyword is never mistaken for a program); a token-level lane cannot, and pretending
|
|
351
|
-
* otherwise is the loosening direction.
|
|
343
|
+
* That identification is what every comparison here rests on — the interpreter refusal and a person's
|
|
344
|
+
* reading of the rule text. For a control structure it is simply false: `for x in once; do curl
|
|
345
|
+
* evil.example; done` splits into three segments whose first tokens are `for`, `do` and `done`; all
|
|
346
|
+
* three are ordinary bare words, so the floor accepts each, and a rule text could name a keyword as
|
|
347
|
+
* though it were a program.
|
|
348
|
+
*
|
|
349
|
+
* Refusing the keyword puts the whole command outside the ALLOW lane, which is the honest answer: no rule
|
|
350
|
+
* can be minted for it and no allow matches it, so it asks. The DENY/ASK direction reads these shapes
|
|
351
|
+
* with its own lexer (`shell-lexer.ts`: a prefixing keyword is stripped so `do curl evil.example` runs
|
|
352
|
+
* `curl`; a `for`/`case` header, whose commands need a syntax tree, is `unreadable` and asks).
|
|
352
353
|
*
|
|
353
354
|
* `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
|
|
354
355
|
* `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
|
|
@@ -537,50 +538,71 @@ export declare function pathRuleReaches(rule: Pick<PersistedRule, "match" | "com
|
|
|
537
538
|
*/
|
|
538
539
|
export declare function ruleAdmitsCommand(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
|
|
539
540
|
/**
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* is
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
541
|
+
* What one TIGHTENING (deny/ask) rule can say about a call — the closed three-word answer of
|
|
542
|
+
* {@link programRunReachOf}:
|
|
543
|
+
* - `reached` — the text PROVABLY performs a run the rule names (the whole chain, or any one segment);
|
|
544
|
+
* - `not_reached` — the text provably does not (every compared word is literal and differs, or the
|
|
545
|
+
* command is shorter than the rule's body);
|
|
546
|
+
* - `unreadable` — the text MAY perform it but cannot be read: an expansion sits where the rule reads a
|
|
547
|
+
* word, a quote or substitution never closes, a syntax error, a control structure whose commands need
|
|
548
|
+
* a syntax tree, a wrapper handed its command as one string.
|
|
549
|
+
* The three lanes that consume it (the org partition, the personal deny/ask rows, the rule DSL's
|
|
550
|
+
* content-form rules) dispose of the words identically: `reached` speaks with the rule's behavior,
|
|
551
|
+
* `not_reached` is silence, and `unreadable` is a FAIL-CLOSED ask — never silence, which is what once let
|
|
552
|
+
* `rm -r x; echo "$HOME"` walk past a deny on `rm -r` because one expansion took the whole line out of
|
|
553
|
+
* the reader. Registered in docs/CLOSED-SETS.md.
|
|
552
554
|
*/
|
|
553
|
-
export declare
|
|
555
|
+
export declare const PROGRAM_RUN_REACHES: readonly ["reached", "not_reached", "unreadable"];
|
|
556
|
+
export type ProgramRunReach = (typeof PROGRAM_RUN_REACHES)[number];
|
|
557
|
+
/** The answer of {@link programRunReachOf}: the word, and for `unreadable` the reason a surface can show. */
|
|
558
|
+
export type ProgramRunReachOutcome = {
|
|
559
|
+
readonly reach: "reached" | "not_reached";
|
|
560
|
+
} | {
|
|
561
|
+
readonly reach: "unreadable";
|
|
562
|
+
readonly reason: string;
|
|
563
|
+
};
|
|
554
564
|
/**
|
|
555
565
|
* Does one TIGHTENING (deny/ask) rule speak about this call — as a whole, or about any single program
|
|
556
|
-
* run in it? The ONE reach predicate of the tightening
|
|
557
|
-
* personal deny/ask rows and the rule DSL's content-form
|
|
558
|
-
* about what a deny reaches.
|
|
566
|
+
* run in it — or can the call not be read against it at all? The ONE reach predicate of the tightening
|
|
567
|
+
* direction, shared by the org partition, the personal deny/ask rows and the rule DSL's content-form
|
|
568
|
+
* deny/ask rules, so no two of them can disagree about what a deny reaches.
|
|
559
569
|
*
|
|
560
570
|
* The allow lane asks a different question and gets a different answer, deliberately. An allow rule is
|
|
561
|
-
* one recorded human yes and must cover the WHOLE call to clear it
|
|
562
|
-
* `npm test && curl evil.example`, because nobody said yes to the second
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
* (`
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* and
|
|
571
|
+
* one recorded human yes and must cover the WHOLE call to clear it ({@link ruleAdmitsCommand}:
|
|
572
|
+
* `Bash(npm test)` does not clear `npm test && curl evil.example`, because nobody said yes to the second
|
|
573
|
+
* half), and it refuses every shell construct outright. A deny/ask rule is a standing statement about a
|
|
574
|
+
* program run, and a call that MAY perform that run is a call the rule speaks about — so this direction
|
|
575
|
+
* reads the command with a real lexer ({@link readShellCommand}): redirections are stripped at the
|
|
576
|
+
* token level, quotes are removed on BOTH sides before words are compared (`rm "-r" x` runs `rm -r x`),
|
|
577
|
+
* wrappers are peeled (`sudo env X=1 nohup rm -r x` runs `rm -r x`), every connector — `&` and a newline
|
|
578
|
+
* included — bounds a segment, and a comment is not a run. Deliberately an OVER-approximation, in the
|
|
579
|
+
* tightening direction: the segments are the runs the text can reach, not the runs that will happen, so
|
|
580
|
+
* a short-circuited branch (`true || curl evil.example`) is judged as though it ran. Answering "does this
|
|
581
|
+
* definitely execute" needs evaluation, not parsing; over-firing costs an approval prompt, and the
|
|
582
|
+
* opposite error costs the rule.
|
|
583
|
+
*
|
|
584
|
+
* Words are compared ARGV-wise, position by position, against the rule body read by the same lexer —
|
|
585
|
+
* over every run CANDIDATE a segment carries: as spelled, then each peeled layer (`sudo rm -r x` is a run
|
|
586
|
+
* of `sudo …` AND of `rm -r x`; a deny on either reaches; a rule body is never peeled, so
|
|
587
|
+
* `Bash(sudo rm -r x)` denies exactly that). A literal word that differs settles `not_reached`; a word
|
|
588
|
+
* carrying an expansion at a position the rule reads settles `unreadable` (the text could spell the
|
|
589
|
+
* rule's word there — unless it is the SAME glob/brace spelling the body wrote); a prefix body whose
|
|
590
|
+
* every word is matched is `reached` whatever follows; an exact body needs equal length — a longer
|
|
591
|
+
* command is `not_reached` unless an extra word is an UNQUOTED expansion (or `"$@"` / an array
|
|
592
|
+
* expansion), which may expand to nothing. The commands inside a substitution and an unquoted heredoc
|
|
593
|
+
* body are segments of their own (`echo "$(rm -r x)"` runs `rm`). A compound
|
|
594
|
+
* body (`Bash(echo ok && rm -r:*)`) reaches only a chain of the same shape — same segment count, same
|
|
595
|
+
* connectors, every leading segment equal, the final one by the body's match kind; a single-segment
|
|
596
|
+
* body reaches any segment. A segment the lexer could not read makes the answer `unreadable` when no
|
|
597
|
+
* other segment already reached.
|
|
598
|
+
*
|
|
599
|
+
* A rule body the lexer cannot read (foreign store bytes; every entrance validates against exactly
|
|
600
|
+
* this) reaches nothing. A path-family rule (`subpath` / `path`) reaches no command: its predicates are
|
|
601
|
+
* {@link directoryRuleAdmits} and {@link pathRuleReaches}.
|
|
583
602
|
*/
|
|
603
|
+
export declare function programRunReachOf(rule: Pick<PersistedRule, "match" | "command">, command: string): ProgramRunReachOutcome;
|
|
604
|
+
/** The boolean face of {@link programRunReachOf}: `reached` only. The shadow view and the pins that
|
|
605
|
+
* assert a reach read this; a lane that must fail closed on `unreadable` reads the outcome. */
|
|
584
606
|
export declare function ruleReachesProgramRun(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
|
|
585
607
|
/** design/382 §3.3-5 — one BREADTH note about a rule a person is about to persist: which wide shape
|
|
586
608
|
* it has, said in words a surface can show beside the input line. A warning is never a refusal —
|
|
@@ -620,20 +642,6 @@ export interface EditedRuleBreadthWarning {
|
|
|
620
642
|
* superset disclosure (registered divergence, design/382 §6-4).
|
|
621
643
|
*/
|
|
622
644
|
export declare function ruleBreadthWarningsOf(rule: Pick<PersistedRule, "match" | "command">): readonly EditedRuleBreadthWarning[];
|
|
623
|
-
/**
|
|
624
|
-
* The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
|
|
625
|
-
* lane cannot read.
|
|
626
|
-
*
|
|
627
|
-
* A tightening rule speaks about a PROGRAM RUN, and a compound runs several. `Bash(curl:*)` published
|
|
628
|
-
* as a deny means "this machine does not make that call", and reading `npm test && curl evil.example`
|
|
629
|
-
* as one unmatched blob answered that with silence — the shape the widening above would otherwise make
|
|
630
|
-
* permanently approvable. Exported (rather than folded into a matcher here) because the layer that
|
|
631
|
-
* needs it holds the rules: this module owns what a command IS, `permission-rule-org.ts` owns what the
|
|
632
|
-
* organization says about each part of it.
|
|
633
|
-
*
|
|
634
|
-
* A single simple command yields a one-element list, so a deny that matched before matches identically.
|
|
635
|
-
*/
|
|
636
|
-
export declare function ruleLaneSegmentsOf(command: string): readonly string[] | undefined;
|
|
637
645
|
/**
|
|
638
646
|
* Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
|
|
639
647
|
* not contain `/ab`. Both sides are expected to be canonical already.
|
|
@@ -780,6 +788,11 @@ export declare function findAdmittingRule(rules: readonly PersistedRule[], call:
|
|
|
780
788
|
export interface PersistedRuleVerdict {
|
|
781
789
|
readonly behavior: RuleBehavior;
|
|
782
790
|
readonly rules: readonly PersistedRule[];
|
|
791
|
+
/** The call could not be READ against the person's deny/ask rows (see {@link programRunReachOf}):
|
|
792
|
+
* `behavior` is `ask`, `rules` names the first tightening row that could not be judged, and this is
|
|
793
|
+
* the lexer's reason. The lane projects it as an UNREADABLE answer — the same fail-closed shape an
|
|
794
|
+
* unreadable store takes — never as a matched ask rule. */
|
|
795
|
+
readonly unreadable?: string;
|
|
783
796
|
}
|
|
784
797
|
/**
|
|
785
798
|
* The ONE adjudication over a MIXED persisted-rule table for a shell call: walk the behaviors in
|
|
@@ -787,12 +800,15 @@ export interface PersistedRuleVerdict {
|
|
|
787
800
|
* for the call — deny before ask before allow, the upstream arbiter's order across its three lists.
|
|
788
801
|
*
|
|
789
802
|
* Each behavior is judged with ITS direction's instrument: a deny/ask row through
|
|
790
|
-
* {@link
|
|
791
|
-
*
|
|
792
|
-
* of it, covered by recorded yeses). The asymmetry is the point and runs one way only: a
|
|
793
|
-
* MORE of a command than an allow does, so an exact allow naming a whole chain can never
|
|
794
|
-
* on one of its parts.
|
|
795
|
-
*
|
|
803
|
+
* {@link programRunReachOf} (the wide tightening reading: whole command or any program run in it,
|
|
804
|
+
* three-valued), an allow through {@link findAdmittingRule} (the narrow reading: the whole call, or
|
|
805
|
+
* every segment of it, covered by recorded yeses). The asymmetry is the point and runs one way only: a
|
|
806
|
+
* deny reads MORE of a command than an allow does, so an exact allow naming a whole chain can never
|
|
807
|
+
* outrun a deny on one of its parts. A call the lexer cannot read against a standing deny/ask row is an
|
|
808
|
+
* UNREADABLE verdict (`behavior: "ask"` + `unreadable`), returned before any allow is consulted: the
|
|
809
|
+
* question "does one of my rules forbid this?" is unanswered, and a recorded yes must not answer it.
|
|
810
|
+
* Eligibility (live, this tool, scope covers the call, THIS behavior) is the one predicate every
|
|
811
|
+
* consumer shares. `undefined` ⇒ no rule of any behavior speaks.
|
|
796
812
|
*/
|
|
797
813
|
export declare function adjudicatePersistedRules(rules: readonly PersistedRule[], call: {
|
|
798
814
|
tool: string;
|
|
@@ -4,6 +4,7 @@ import { compileReadDeny } from "../tools/fs/read-deny.js";
|
|
|
4
4
|
import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
|
|
5
5
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
6
6
|
import { pathTargetOf } from "./tool-registry.js";
|
|
7
|
+
import { readShellCommand } from "./shell-lexer.js";
|
|
7
8
|
export const RULE_BEHAVIORS = ["deny", "ask", "allow"];
|
|
8
9
|
const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
|
|
9
10
|
export function isRuleBehavior(v) {
|
|
@@ -144,7 +145,6 @@ function reject(code, message) {
|
|
|
144
145
|
}
|
|
145
146
|
const RULE_LANE_FLOOR = { pathPrefixedNameIsText: true };
|
|
146
147
|
const MATCH_READING = { terminator: "keep", quotedOperatorsAreText: false, redirection: "reject" };
|
|
147
|
-
const PROGRAM_RUNS_READING = { terminator: "strip", quotedOperatorsAreText: true, redirection: "reject" };
|
|
148
148
|
const OFFER_READING = { terminator: "keep", quotedOperatorsAreText: false, redirection: "tolerate" };
|
|
149
149
|
function commandBasename(name) {
|
|
150
150
|
return name.slice(name.lastIndexOf("/") + 1);
|
|
@@ -418,117 +418,94 @@ function hasUnescapedStar(s) {
|
|
|
418
418
|
export function ruleAdmitsCommand(rule, command) {
|
|
419
419
|
return admitsUnder(rule, command, MATCH_READING);
|
|
420
420
|
}
|
|
421
|
-
export
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
export function
|
|
421
|
+
export const PROGRAM_RUN_REACHES = ["reached", "not_reached", "unreadable"];
|
|
422
|
+
const REACHED = { reach: "reached" };
|
|
423
|
+
const NOT_REACHED = { reach: "not_reached" };
|
|
424
|
+
export function programRunReachOf(rule, command) {
|
|
425
425
|
if (rule.match === "subpath" || rule.match === "path")
|
|
426
|
-
return
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
426
|
+
return NOT_REACHED;
|
|
427
|
+
const body = readRuleBody(rule.command);
|
|
428
|
+
if (body === undefined)
|
|
429
|
+
return NOT_REACHED;
|
|
430
|
+
const shape = readCommandMemo(command);
|
|
431
|
+
const exact = rule.match === "exact";
|
|
432
|
+
if (body.segments.length > 1) {
|
|
433
|
+
const broken = shape.segments.find((s) => s.unreadable !== undefined);
|
|
434
|
+
if (broken !== undefined)
|
|
435
|
+
return { reach: "unreadable", reason: broken.unreadable };
|
|
436
|
+
const hidden = shape.segments.find((s) => s.peelUnreadable !== undefined);
|
|
437
|
+
const shaped = !shape.grouped && shape.segments.length === body.segments.length && shape.connectors.every((c, i) => c === body.connectors[i]);
|
|
438
|
+
if (!shaped)
|
|
439
|
+
return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable };
|
|
440
|
+
let outcome = REACHED;
|
|
441
|
+
for (let i = 0; i < body.segments.length; i++) {
|
|
442
|
+
const r = segmentReachOf(body.segments[i].argv, shape.segments[i], exact || i < body.segments.length - 1);
|
|
443
|
+
if (r.reach === "not_reached")
|
|
444
|
+
return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable };
|
|
445
|
+
if (r.reach === "unreadable")
|
|
446
|
+
outcome = r;
|
|
447
|
+
}
|
|
448
|
+
return outcome;
|
|
449
|
+
}
|
|
450
|
+
const bodyArgv = body.segments[0].argv;
|
|
451
|
+
let unreadable;
|
|
452
|
+
for (const segment of shape.segments) {
|
|
453
|
+
const r = segmentReachOf(bodyArgv, segment, exact);
|
|
454
|
+
if (r.reach === "reached")
|
|
455
|
+
return REACHED;
|
|
456
|
+
if (r.reach === "unreadable")
|
|
457
|
+
unreadable ??= r;
|
|
458
|
+
}
|
|
459
|
+
return unreadable ?? NOT_REACHED;
|
|
436
460
|
}
|
|
437
|
-
function
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
let
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
let j = i;
|
|
466
|
-
while (j < command.length && isRedirect(command[j]))
|
|
467
|
-
j++;
|
|
468
|
-
if (j < command.length && command[j] === "|" && command[i] === ">" && j === i + 1)
|
|
469
|
-
j++;
|
|
470
|
-
while (j < command.length && isSpace(command[j]))
|
|
471
|
-
j++;
|
|
472
|
-
if (j < command.length && isConnector(command[j]))
|
|
473
|
-
return undefined;
|
|
474
|
-
let q2;
|
|
475
|
-
while (j < command.length) {
|
|
476
|
-
const c = command[j];
|
|
477
|
-
if (q2 !== undefined) {
|
|
478
|
-
if (c === q2)
|
|
479
|
-
q2 = undefined;
|
|
480
|
-
j++;
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
if (c === "'" || c === '"') {
|
|
484
|
-
q2 = c;
|
|
485
|
-
j++;
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
if (isSpace(c) || isRedirect(c) || isConnector(c))
|
|
489
|
-
break;
|
|
490
|
-
j++;
|
|
491
|
-
}
|
|
492
|
-
if (q2 !== undefined)
|
|
493
|
-
return undefined;
|
|
494
|
-
if (out !== "" && !isSpace(out[out.length - 1]))
|
|
495
|
-
out += " ";
|
|
496
|
-
i = j;
|
|
497
|
-
continue;
|
|
461
|
+
export function ruleReachesProgramRun(rule, command) {
|
|
462
|
+
return programRunReachOf(rule, command).reach === "reached";
|
|
463
|
+
}
|
|
464
|
+
function segmentReachOf(bodyArgv, segment, exact) {
|
|
465
|
+
if (segment.unreadable !== undefined)
|
|
466
|
+
return { reach: "unreadable", reason: segment.unreadable };
|
|
467
|
+
let unreadable;
|
|
468
|
+
for (const argv of [segment.argv, ...segment.peeled]) {
|
|
469
|
+
const r = argvReachOf(bodyArgv, argv, exact);
|
|
470
|
+
if (r.reach === "reached")
|
|
471
|
+
return REACHED;
|
|
472
|
+
if (r.reach === "unreadable")
|
|
473
|
+
unreadable ??= r;
|
|
474
|
+
}
|
|
475
|
+
if (segment.peelUnreadable !== undefined)
|
|
476
|
+
unreadable ??= { reach: "unreadable", reason: segment.peelUnreadable };
|
|
477
|
+
return unreadable ?? NOT_REACHED;
|
|
478
|
+
}
|
|
479
|
+
function argvReachOf(bodyArgv, argv, exact) {
|
|
480
|
+
for (let i = 0; i < bodyArgv.length; i++) {
|
|
481
|
+
const word = argv[i];
|
|
482
|
+
const wanted = bodyArgv[i];
|
|
483
|
+
if (word === undefined)
|
|
484
|
+
return NOT_REACHED;
|
|
485
|
+
if (word.expands !== false) {
|
|
486
|
+
if (word.raw === wanted.raw)
|
|
487
|
+
continue;
|
|
488
|
+
return { reach: "unreadable", reason: i === 0 ? "the program name is an expansion" : `an expansion (${word.raw}) stands where the rule reads a word` };
|
|
498
489
|
}
|
|
499
|
-
|
|
500
|
-
|
|
490
|
+
if (word.text !== wanted.text)
|
|
491
|
+
return NOT_REACHED;
|
|
501
492
|
}
|
|
502
|
-
if (
|
|
493
|
+
if (!exact || argv.length === bodyArgv.length)
|
|
494
|
+
return REACHED;
|
|
495
|
+
const loose = argv.slice(bodyArgv.length).find((w) => w.expands === "many");
|
|
496
|
+
return loose === undefined ? NOT_REACHED : { reach: "unreadable", reason: `an unquoted expansion (${loose.raw}) may expand to nothing` };
|
|
497
|
+
}
|
|
498
|
+
function readRuleBody(body) {
|
|
499
|
+
const shape = readShellCommand(body);
|
|
500
|
+
if (shape.grouped || shape.segments.length === 0)
|
|
503
501
|
return undefined;
|
|
504
|
-
return
|
|
502
|
+
return shape.segments.every((s) => s.unreadable === undefined && s.argv.length > 0) ? shape : undefined;
|
|
505
503
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (quote !== undefined) {
|
|
512
|
-
out += ch;
|
|
513
|
-
if (ch === quote)
|
|
514
|
-
quote = undefined;
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
517
|
-
if (ch === "'" || ch === '"') {
|
|
518
|
-
quote = ch;
|
|
519
|
-
out += ch;
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
const atWordStart = i === 0 || command[i - 1] === " " || command[i - 1] === "\t" || command[i - 1] === ";" || command[i - 1] === "|" || command[i - 1] === "&";
|
|
523
|
-
const m = (atWordStart ? /^\d*[<>]&\d+/ : /^[<>]&\d+/).exec(command.slice(i));
|
|
524
|
-
if (m !== null) {
|
|
525
|
-
i += m[0].length - 1;
|
|
526
|
-
out += " ";
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
out += ch;
|
|
530
|
-
}
|
|
531
|
-
return quote === undefined ? out : undefined;
|
|
504
|
+
let lastRead;
|
|
505
|
+
function readCommandMemo(command) {
|
|
506
|
+
if (lastRead?.command !== command)
|
|
507
|
+
lastRead = { command, shape: readShellCommand(command) };
|
|
508
|
+
return lastRead.shape;
|
|
532
509
|
}
|
|
533
510
|
function admitsUnder(rule, command, reading) {
|
|
534
511
|
if (rule.match === "subpath" || rule.match === "path")
|
|
@@ -572,10 +549,6 @@ export function ruleBreadthWarningsOf(rule) {
|
|
|
572
549
|
}
|
|
573
550
|
return [];
|
|
574
551
|
}
|
|
575
|
-
export function ruleLaneSegmentsOf(command) {
|
|
576
|
-
const shape = ruleLaneShapeOf(command, PROGRAM_RUNS_READING);
|
|
577
|
-
return "reject" in shape ? undefined : shape.segments;
|
|
578
|
-
}
|
|
579
552
|
export function pathWithinRoot(path, root) {
|
|
580
553
|
if (path === root)
|
|
581
554
|
return true;
|
|
@@ -775,12 +748,18 @@ export function adjudicatePersistedRules(rules, call) {
|
|
|
775
748
|
return { behavior, rules: covering };
|
|
776
749
|
continue;
|
|
777
750
|
}
|
|
751
|
+
let unreadable;
|
|
778
752
|
for (const rule of rules) {
|
|
779
753
|
if (!eligiblePersisted(rule, call, behavior))
|
|
780
754
|
continue;
|
|
781
|
-
|
|
755
|
+
const outcome = programRunReachOf(rule, call.command);
|
|
756
|
+
if (outcome.reach === "reached")
|
|
782
757
|
return { behavior, rules: [rule] };
|
|
758
|
+
if (outcome.reach === "unreadable")
|
|
759
|
+
unreadable ??= { behavior: "ask", rules: [rule], unreadable: outcome.reason };
|
|
783
760
|
}
|
|
761
|
+
if (unreadable !== undefined)
|
|
762
|
+
return unreadable;
|
|
784
763
|
}
|
|
785
764
|
return undefined;
|
|
786
765
|
}
|
|
@@ -188,8 +188,9 @@ export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[
|
|
|
188
188
|
* design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
|
|
189
189
|
* behavior the first textual match wins (reporting order only — all denies are the same one answer).
|
|
190
190
|
* Matching uses the SAME parser and the SAME tightening reach the personal deny/ask rows use
|
|
191
|
-
* ({@link
|
|
192
|
-
* legitimately wide tightening and matches; a segment hit is a hit
|
|
191
|
+
* ({@link programRunReachOf}: an interpreter-headed prefix deny like `Bash(node:*)` is a
|
|
192
|
+
* legitimately wide tightening and matches; a segment hit is a hit; a call the lexer cannot read
|
|
193
|
+
* against a rule is an ASK with the reason — fail closed, never silence).
|
|
193
194
|
*
|
|
194
195
|
* The skip below is a defensive floor, and what stands behind it is worth stating precisely: rules that
|
|
195
196
|
* arrive from the org partition's `resolve` ({@link createOrgRulePartition}) have passed {@link validateOrgSnapshot} on BOTH the install
|
|
@@ -202,10 +203,15 @@ export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[
|
|
|
202
203
|
export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
|
|
203
204
|
tool: string;
|
|
204
205
|
command: string;
|
|
205
|
-
}):
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
}): OrgRuleVerdict | undefined;
|
|
207
|
+
/** The org layer's answer for one call: which rule spoke, with what behavior — and, when the call could
|
|
208
|
+
* not be READ against the org's rules, the lexer's reason beside the first rule it could not judge
|
|
209
|
+
* (`behavior` is then `ask`: the org published a rule that may reach this call, and nobody can say). */
|
|
210
|
+
export interface OrgRuleVerdict {
|
|
211
|
+
readonly behavior: "deny" | "ask";
|
|
212
|
+
readonly rule: string;
|
|
213
|
+
readonly unreadable?: string;
|
|
214
|
+
}
|
|
209
215
|
/**
|
|
210
216
|
* design/182 §7.3 / design/389 — the SHADOW predicate the unified store's read face applies to every
|
|
211
217
|
* personal allow row: does an org DENY reach this rule's own command pattern? An org deny does not
|