@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/agents/child-model-seat.d.ts +51 -0
  3. package/dist/agents/child-model-seat.js +34 -0
  4. package/dist/agents/subagent.d.ts +4 -2
  5. package/dist/agents/subagent.js +16 -12
  6. package/dist/core/ask-origin.d.ts +7 -3
  7. package/dist/core/checkpoint-store.d.ts +2 -7
  8. package/dist/core/effective-path-target.d.ts +43 -0
  9. package/dist/core/effective-path-target.js +56 -0
  10. package/dist/core/engine-notice.d.ts +8 -0
  11. package/dist/core/fs-write-gate-policy.js +2 -1
  12. package/dist/core/gate-lanes.d.ts +1 -0
  13. package/dist/core/gate-lanes.js +11 -5
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +2 -0
  16. package/dist/core/hooks.d.ts +10 -1
  17. package/dist/core/mcp-injection-drop.d.ts +74 -0
  18. package/dist/core/mcp-injection-drop.js +27 -0
  19. package/dist/core/permission-rule-model.d.ts +91 -75
  20. package/dist/core/permission-rule-model.js +90 -111
  21. package/dist/core/permission-rule-org.d.ts +12 -6
  22. package/dist/core/permission-rule-org.js +9 -3
  23. package/dist/core/permission-rules.d.ts +3 -2
  24. package/dist/core/permission-rules.js +38 -15
  25. package/dist/core/persisted-rule-arms.d.ts +7 -2
  26. package/dist/core/persisted-rule-arms.js +3 -1
  27. package/dist/core/roles.d.ts +8 -0
  28. package/dist/core/runner/active-skill-scope.js +2 -1
  29. package/dist/core/runner/contracts.d.ts +3 -2
  30. package/dist/core/runner/permission-rule-lanes.d.ts +12 -4
  31. package/dist/core/runner/permission-rule-lanes.js +13 -15
  32. package/dist/core/runner/prepare-caps-and-workflow.js +2 -2
  33. package/dist/core/runner/runtask.d.ts +2 -1
  34. package/dist/core/runner/runtask.js +1 -1
  35. package/dist/core/runner/session-rule-policy.js +2 -1
  36. package/dist/core/runner/tool-face-overlay.js +22 -3
  37. package/dist/core/sensitive-path-policy.js +5 -3
  38. package/dist/core/shell-lexer.d.ts +47 -0
  39. package/dist/core/shell-lexer.js +478 -0
  40. package/dist/core/shell-scan.d.ts +60 -0
  41. package/dist/core/shell-scan.js +183 -0
  42. package/dist/core/shell-wrapper-table.d.ts +297 -0
  43. package/dist/core/shell-wrapper-table.js +58 -0
  44. package/dist/core/tool-catalog-entries.js +6 -6
  45. package/dist/core/tool-face.d.ts +80 -4
  46. package/dist/core/tool-face.js +10 -0
  47. package/dist/core/tool-policy.d.ts +1 -6
  48. package/dist/core/tool-registry.d.ts +8 -11
  49. package/dist/core/tool-registry.js +5 -2
  50. package/dist/core/tool-roster.d.ts +11 -2
  51. package/dist/core/tool-roster.js +21 -3
  52. package/dist/index.d.ts +6 -3
  53. package/dist/index.js +5 -2
  54. package/dist/orchestration/workflow.js +21 -4
  55. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  56. package/dist/tools/fs/fs-search-tools.js +17 -9
  57. package/dist/tools/fs/index.js +1 -1
  58. package/dist/tools/fs/search.d.ts +0 -8
  59. package/dist/tools/fs/search.js +0 -23
  60. package/package.json +1 -1
  61. 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 ruleReachesProgramRun} — whole command or
18
- * any segment of a chain, the wider tightening reading). Precedence across behaviors is the upstream
19
- * one, deny > ask > allow ({@link RULE_BEHAVIOR_PRECEDENCE}, the one table every adjudication reads).
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 loosening face shows up as a circumvention rather than as a test failure.
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, the deny/ask
341
- * layer's per-segment judgement, and a person's reading of the rule text. For a control structure it is
342
- * simply false. `for x in once; do curl evil.example; done` splits into three segments whose first
343
- * tokens are `for`, `do` and `done`; all three are ordinary bare words, so the floor accepts each,
344
- * `curl` is named by nothing, and a published `deny Bash(curl:*)` matches none of them while bash runs
345
- * curl. A whole loop body — any number of programs — hides behind three tokens that name no program.
346
- *
347
- * Refusing the keyword puts the whole command outside the lane, which is the honest answer: no rule can
348
- * be minted for it and no rule matches it, so it asks. Recovering these shapes properly needs a real
349
- * shell grammar (upstream has one it parses to a syntax tree and reads the commands out of the
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
- * The same match, asked the DENY/ASK layer's question "does this rule speak about this program run?".
541
- *
542
- * Split from {@link ruleAdmitsCommand} because the two differ on one axis that decides real cases: a
543
- * quoted operator. `curl "https://x/?a=1&b=2"` is ONE command bash runs, and the `&` in a query string
544
- * is not a connector; the matching side refuses it anyway (a rule text is a spelling with no operator
545
- * characters in it at all the historical rule-face contract), and the deny side inheriting that
546
- * refusal made `deny Bash(curl:*)` silent on the commonest spelling of the very program it names
547
- * (adversarial round 3).
548
- *
549
- * The asymmetry only ever runs one way: this predicate reads MORE commands than the matching one, never
550
- * fewer. A shape only the ALLOW side could read would be a standing approval no published policy could
551
- * see the inversion this whole ticket exists to prevent.
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 function ruleAdmitsProgramRun(rule: Pick<PersistedRule, "match" | "command">, command: string): boolean;
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 direction, shared by the org partition, the
557
- * personal deny/ask rows and the rule DSL's content-form deny/ask rules, so no two of them can disagree
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: `Bash(npm test)` does not clear
562
- * `npm test && curl evil.example`, because nobody said yes to the second half. A deny/ask rule is a
563
- * standing statement about a program run, and a call that MAY perform that run is a call the rule
564
- * speaks about so a segment hit is a hit. Deliberately an OVER-approximation, in the tightening
565
- * direction: the segments are the runs the text can reach, not the runs that will happen, so a
566
- * short-circuited branch (`true || curl evil.example`) and a commented-out tail
567
- * (`echo ok # ; curl evil.example`) are judged as though they ran. Answering "does this definitely
568
- * execute" needs evaluation, not parsing; over-firing costs an approval prompt, and the opposite error
569
- * costs the rule. Reading the compound as one unmatched blob is what once made `deny Bash(curl:*)`
570
- * silent on `npm test && curl evil.example`: a rule standing on paper and matching nothing in fact.
571
- *
572
- * `ruleAdmitsProgramRun`, not `ruleAdmitsCommand`, on BOTH arms — this direction asks which programs
573
- * the call runs, and the matching predicate refuses a quoted operator that is ordinary argument text
574
- * (a `&` inside a URL); inheriting that refusal here silenced a deny on the commonest curl spelling.
575
- * EVERY segment is judged, including the sole segment of a one-element list: the two readings differ
576
- * on terminator handling, so `curl evil.example;` has a single segment `curl evil.example` that the
577
- * whole-command arm refuses and this arm must still see (a trailing `;` once carried a chain past the
578
- * fence). A command the lane cannot read at all (a redirection, a substitution) has no segments; the
579
- * whole-command arm still runs and answers false, as it always did those shapes are refused upstream.
580
- *
581
- * A path-family rule (`subpath` / `path`) reaches no command: its predicates are {@link directoryRuleAdmits}
582
- * and {@link pathRuleReaches}.
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 ruleReachesProgramRun} (the wide tightening reading: whole command or any program run in
791
- * it), an allow through {@link findAdmittingRule} (the narrow reading: the whole call, or every segment
792
- * of it, covered by recorded yeses). The asymmetry is the point and runs one way only: a deny reads
793
- * MORE of a command than an allow does, so an exact allow naming a whole chain can never outrun a deny
794
- * on one of its parts. Eligibility (live, this tool, scope covers the call, THIS behavior) is the one
795
- * predicate every consumer shares. `undefined` no rule of any behavior speaks.
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 function ruleAdmitsProgramRun(rule, command) {
422
- return admitsUnder(rule, command, PROGRAM_RUNS_READING);
423
- }
424
- export function ruleReachesProgramRun(rule, command) {
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 false;
427
- if (ruleAdmitsProgramRun(rule, command))
428
- return true;
429
- const spelled = stripRedirections(stripFdDuplications(command) ?? command) ?? command;
430
- if (spelled !== command && ruleAdmitsProgramRun(rule, spelled))
431
- return true;
432
- const segments = ruleLaneSegmentsOf(spelled);
433
- if (segments === undefined)
434
- return false;
435
- return segments.some((segment) => ruleAdmitsProgramRun(rule, segment));
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 stripRedirections(command) {
438
- const isSpace = (c) => c === " " || c === "\t";
439
- const isRedirect = (c) => c === "<" || c === ">";
440
- const isConnector = (c) => c === "|" || c === "&" || c === ";";
441
- let out = "";
442
- let quote;
443
- let i = 0;
444
- while (i < command.length) {
445
- const ch = command[i];
446
- if (quote !== undefined) {
447
- out += ch;
448
- if (ch === quote)
449
- quote = undefined;
450
- i++;
451
- continue;
452
- }
453
- if (ch === "'" || ch === '"') {
454
- quote = ch;
455
- out += ch;
456
- i++;
457
- continue;
458
- }
459
- if (isRedirect(ch)) {
460
- let k = out.length;
461
- while (k > 0 && /\d/.test(out[k - 1]))
462
- k--;
463
- if (k < out.length && (k === 0 || isSpace(out[k - 1]) || isConnector(out[k - 1])))
464
- out = out.slice(0, k);
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
- out += ch;
500
- i++;
490
+ if (word.text !== wanted.text)
491
+ return NOT_REACHED;
501
492
  }
502
- if (quote !== undefined)
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 out.trim();
502
+ return shape.segments.every((s) => s.unreadable === undefined && s.argv.length > 0) ? shape : undefined;
505
503
  }
506
- function stripFdDuplications(command) {
507
- let out = "";
508
- let quote;
509
- for (let i = 0; i < command.length; i++) {
510
- const ch = command[i];
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
- if (ruleReachesProgramRun(rule, call.command))
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 ruleReachesProgramRun}: an interpreter-headed prefix deny like `Bash(node:*)` is a
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
- behavior: "deny" | "ask";
207
- rule: string;
208
- } | undefined;
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