@sema-agent/core
Version:
Stateless, task-oriented AI agent core
199 lines (198 loc) • 16.4 kB
TypeScript
/**
* design/127 — **参数级权限规则 DSL**(CC 2.1.201 追平批 γ)。声明式规则字符串 → {@link ToolPolicy}
* 编译器:`"Tool"`(裸)/ `"Tool(param:pattern)"`(参数级,`*` 通配全串锚定)。CC ground truth
* 行号级:解析 `Lh` :55214 · 通配 `ZMr` :55196 · 参数级 matcher `gnn` :595123 · 评估序 `G1e` :595272。
*
* 架构边界(design/127 §0,重判过的「core 不长 rule interpreter」):这是纯函数式、opt-in、零
* run-loop 介入的「声明式数据→ToolPolicy」工厂(`createCoarseCommandNamePolicy` 同形态先例)。
* core 不读设置文件、不管 source 层级合并 —— Settings-loader 把各层合并成**单一规则表**后编译
* **一个** policy。
*
* ## Deny and ask only
* A policy fold says `deny` / `ask`. An allow rule is refused (`config.invalid_permission_rules`, one `unsupported.allow`
* issue per rule): "this does not need asking" lives on the allow layer (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`).
*
* ## The CONTENT-FORM command lane (the shell tool's own grammar)
*
* A `Bash(<command>)` / `Bash(<prefix>:*)` / `Bash(<body> *)` rule is not a `param:pattern` rule — its
* content is a COMMAND SPELLING in the persisted-rule model's grammar (`permission-rule-model.ts`), and
* this compiler now compiles it AS SUCH for the two tightening behaviors: the text goes through the ONE
* validator (`parseRuleText(text, behavior)` — the tightening floor, so `Bash(node:*)` as a deny is a
* legitimately wide tightening) and the call is judged with the ONE tightening reach
* (`programRunReachOf`: the whole command line or any program run in a connector chain — upstream's
* per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use;
* a call the lexer cannot read against a rule is a fail-closed ask, never silence).
* The rule is written in the command word (`Bash`) and reaches every call on the catalog's SHELL FACE — `Bash`, and
* `Monitor`, which runs its `command` through the same execution seam — under any name the call answers to; a command
* spelling on another shell-face name (`Monitor(touch:*)`) is refused (`invalid.command_rule`, naming the `Bash(…)` form):
* upstream reads no such rule. A bare `Bash` rule is a tool-name rule and reaches the tool of that name only.
* It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
* is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
* ask → `AN(…,"ask")` field-level ask).
*
* The PATH lane is the same shape for every tool the catalog declares a path target for: a
* `Edit(~/.sema/settings.json)` / `Write(src/**)` / `Read(//etc/**)` deny or ask compiles through the same
* validator (the model's path grammar — the `//abs-dir/**` directory form and upstream's pattern form:
* `//` absolute, `~/` home, `/` project root, otherwise working-directory relative; `*` within a segment,
* `**` across) and is judged on the call's PROTECTIVE path target (design/388: the object face's declaration, the
* catalog's for the name as the floor), every view of it (`callTargetViewsOf`), by the one judgment every path rule
* meets (`judgePathRows`). A pattern's ANCHORS are the rule's own fact, taken once where the policy is installed: the
* declared `pathBases` word by word, else the leg the Runner installs the policy on (`policyAnchors`) — never the
* call's live working directory. A declared base that is not a usable absolute path is refused at compile time
* (`unsupported.path_base`); a policy called outside a Runner leg with no declared base reads such a rule
* `unreadable` (the fail-closed ask).
* A `Read(...)` / `Edit(...)` deny or ask reaches the calls on its behavior's FACES (`PATH_RULE_FACES`), not only
* its own tool: a `Read` ask asks for a Grep / Glob rooted in its path, judged on that call's own target.
*
* The NAME lane is the same shape for the tools of `NAME_RULE_FACES` (the `Skill` tool): a `Skill(<name>)` / `Skill(<prefix>:*)`
* deny or ask — the reference's own spelling — compiles through the same validator (the model's name grammar) and is judged on the
* one string the call names (`nameRuleSubjectOf`) with `nameRuleAdmits`, the reader the persisted rule store uses. The parameter
* spelling `Skill(skill:<name>)` those tools used to take is retired: refused loudly, its sentence naming the CC form. A KEYED
* value tool (`Artifact(action:<word>)`) takes the same lane for content keyed by its own parameter (`nameGrammarReads`); its
* other arguments stay on the generic `param:pattern` lane. A call of it that names no value for the key as the tool reads it
* is refused by the key's deny or ask rule (`keyedValueAbsentReason`, the persisted lane's reading).
*
* 🔴 与 CC 的有意差(design/127 r2):
* - CC 参数级 generic 道无 allow lane(gnn 只有 deny/ask)—— 我们同样,且整个 policy 无 allow 臂。
* - CC 对尾缀垃圾静默降级为裸名(Lh `n !== len-1`)—— `parsePermissionRule` 保持 CC parity,但
* **工厂 validator strict**:降级即 invalid(fail-fast 世界观,写错的规则不能静默变宽)。
* - 已知主字段参数规则(如 `Bash(command:…)`)默认 **reject**:通配是 trim 后**整串 lexical**
* 匹配,不是 shell AST / 前缀 lane —— `Bash(command:git push*)` 匹配不了 `cd /x && git push`,
* 「以为挡住了」比没挡更危险。`primaryFieldGeneric:"allow-lexical"` 显式解锁。
*/
import { type NamedToolPolicy } from "./tool-policy.js";
import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js";
import { type PathRuleBases } from "./permission-rule-model.js";
export { parsePermissionRule, type ParsedPermissionRule };
export interface PermissionRule {
/** CC 语法:`"Tool"` 或 `"Tool(param:pattern)"`。`Tool()`/`Tool(*)` 归一为裸规则;括号可 `\(`/`\)` 转义。 */
rule: string;
behavior: "deny" | "ask";
/** Provenance:进 deny/ask 的 message 供审计(e.g. `"policySettings"`)。层级
* 优先级/tighten-only 语义在 Settings-loader,不在这里。 */
source?: string;
}
export interface PermissionRuleIssue {
rule: string;
/** `invalid.*` = 语法/语义错;`unsupported.*` = CC 合法但 v1 不支持的 lane(独立
* 分类,绝不静默 no-op —— `Bash(npm run:*)` 被当 generic 参数规则接受后永不匹配是最危险的失败形态)。 */
code: "invalid.empty_tool" | "invalid.paren"
/** A rule the rule syntax reads no rule in (`ruleSpellingOf`) whose name is the reason: blanks around the rule, a
* character outside a tool name's spelling (a variation selector, a filler, a zero-width space), a blank or an invisible
* character before the `(`, a leading `-`. Refused — the lenient reading would deny a tool under another spelling, or none.
* Also a rule whose tool name the syntax reads as the wildcard `*` (`*`, `*()`, `*(*)`): a rule reaches the tool it
* names, and `*` names none. */
| "invalid.tool_name" | "invalid.param_split" | "invalid.primary_field" | "invalid.cap_exceeded"
/** An ALLOW rule (any spelling): a policy fold says deny / ask only — "this does not need asking" is the allow
* layer's (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`). One issue per allow rule. */
| "unsupported.allow"
/** A `behavior` outside the closed set `deny | ask` (a JS caller's typo, an undefined member, a boxed string): refused —
* a rule that matched nothing because its verb was misspelled would be a deny that silently never fires. */
| "invalid.behavior"
/** A content-form deny/ask rule the ONE rule validator refuses — the shell's command grammar (a redirection, a substitution,
* a mid-pattern star, control characters…), a path tool's path grammar, a name tool's name grammar (the retired parameter
* spelling `Skill(skill:<name>)`, an empty name…) — the message carries the validator's own code and sentence. */
| "invalid.command_rule"
/** An `allowedTools` value (the request's `TaskSpec.allowedTools`, the deployment's `RunnerDeps.allowedTools`) that is
* not a list of strings: one issue for a non-list, one per element that is not a string (`rule` is empty — there is
* no rule text to name). */
| "invalid.entry_type"
/** design/388 B15 — the closed rule-lint set over a tool's RULE FACE (derived scalar vocabulary; a
* declaration only narrows it): the named parameter is not one the tool carries; it is carried but
* is an object/array (a rule on it can never match); the tool is unknown to the resolver a leg
* supplied (absent resolver ⇒ the catalog alone judges and an unknown tool stays permissive). */
| "unsupported.param_unknown" | "unsupported.param_non_scalar" | "unsupported.tool_unknown"
/** A path-form deny/ask whose spelling needs a base this policy DECLARES as something other than an absolute path
* of its tree's family (`/x` reads `pathBases.root`; a relative pattern `pathBases.cwd`, else `root`; `~/` `home`)
* — a bad declared value, refused rather than compiled into a rule that reaches nothing. An undeclared base is no
* refusal: the rule is anchored where the policy is installed. */
| "unsupported.path_base" | "unsupported.mcp_paren"
/** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace
* (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a
* covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and
* matched nothing. Additive member; the MCP spelling keeps its own {@link
* PermissionRuleIssue.code} `unsupported.mcp_paren` (that refusal is wider — it covers exact MCP
* names too, for CC parity — and fires first, so no MCP rule's reported code changes). */
| "unsupported.covering_paren"
/** HRD-PRM-7: a param-level rule naming a parameter the target tool does not carry. Additive member —
* the lane that used to compile silently and never match (see {@link FILE_TOOL_PARAMS}). */
| "unsupported.unknown_param";
message: string;
}
export interface PermissionRuleCaps {
maxRules: number;
maxRuleLength: number;
maxPatternLength: number;
maxStars: number;
/** Scalar arg values longer than this are NOT matched textually — the rule **fail-closes** to its
* lane (a deny rule targeting an oversized value denies; an ask rule asks). 租户规则 × 模型巨串
* 的热路径成本上界。 */
maxScalarValueChars: number;
}
/** The caps a rule set is held to unless the caller names its own — `maxRules` is the ONE bound on how many rules a set
* carries, read by the compiler here and by the org layer's list judgment (which refuses a longer list before it is copied). */
export declare const DEFAULT_CAPS: Readonly<PermissionRuleCaps>;
export interface PermissionRulePolicyOptions {
/** Invalid/unsupported rules: `"throw"`(default — 部署作者写错要立刻知道)aggregates ALL issues
* into one error; `"skip"` drops them (pair with {@link validatePermissionRules} for a report, and/or
* supply {@link onIssue}). Exception(实现审 M3):`invalid.cap_exceeded` on the WHOLE rule set
* (maxRules) throws EVEN under `"skip"` — skipping it would compile an EMPTY table and fail-open. */
onInvalidRule?: "throw" | "skip";
/** Called once per dropped rule under `"skip"` (audit surface — silent drops are how a deployment
* discovers, months later, that its deny never existed). Default: one aggregated `console.warn`. */
onIssue?: (issue: PermissionRuleIssue) => void;
/** What the policy returns when NO rule matches. Default `"allow"` = 本 policy 无意见(组合语义;不是同意)。
* default-closed 部署用 `"ask"`/`"deny"` —— 必须在这 INSIDE the policy(链尾 ask policy 会改写整条链的答案)。 */
defaultAction?: "allow" | "ask" | "deny";
/** 已知主字段参数规则(`Bash(command:…)` 等)的处置。默认 `"reject"`(整串 lexical ≠ 前缀/AST,
* 假安全感);`"allow-lexical"` 显式接受并按 generic 语义匹配。 */
primaryFieldGeneric?: "reject" | "allow-lexical";
caps?: Partial<PermissionRuleCaps>;
/**
* design/388 B15 — the leg's RULE-FACE resolver (typically `ruleFacesFromRoster(prepared.toolRoster)`): with it
* every tool on the leg is judged — MCP and caller tools included — and a rule naming a tool the leg does not
* mount is `unsupported.tool_unknown`. Without it the static catalog alone judges; a tool the catalog does not
* carry stays permissive (a rule for it compiles unjudged).
*/
ruleFaces?: (toolName: string) => RuleFaceView | undefined;
/**
* The ANCHORS of this rule set's path patterns ({@link PathRuleBases}), word by word: `root` for `/…`, `cwd` for a
* relative pattern (the declared root stands in when absent — the word's own meaning), `home` for `~/…` (an
* execution environment's home — never the engine process's own; #644). A word declared here anchors the rules
* wherever the policy runs; a word left out is anchored where the Runner installs the policy — the root and the
* environment's home of the leg it enters the delegation chain on — and stays there for every child and every `cd`.
* A declared value that is not an absolute path of its tree's family is refused at compile time
* (`unsupported.path_base`, naming the base — {@link ruleBasesNeeded} is the same classification).
* These are the RULES' anchors, never the call's: a call's target is read from the call (its target reading, else its
* own `root` / `cwd`); only a host calling the policy directly with a request that carries neither has its target
* resolved against `root` / `cwd` here. A target nothing locates is unreadable: every path rule able to reach the
* call answers it with the fail-closed ask (`ruleUnreadable`).
*/
pathBases?: PathRuleBases;
}
import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, wildcardMatch } from "./namespaced-rule-name.js";
export { isNamespacedCoveringRuleName, namespacedRuleNameCovers, wildcardMatch };
/**
* design/388 A-6/7/8/9 (D-3): the per-tool RULE FACE — the parameters a `Tool(param:pattern)` rule may name
* (`params`, the SCALAR top-level keys ∪ the path target's aliases), the ones the generic lexical matcher must
* not be pointed at (`primaryParams`), the carried-but-non-scalar keys a rule can never match (`nonScalar`), and
* whether the tool names a path target at all (the file-tool glob lane). Resolved through
* {@link PermissionRulePolicyOptions.ruleFaces} when a leg supplies its roster, else from the static catalog
* — the three hand-kept tables (`PRIMARY_FIELDS` / `FILE_TOOLS` / `FILE_TOOL_PARAMS`) this replaced are gone.
*/
export interface RuleFaceView {
primaryParams: readonly string[];
/** The full scalar vocabulary; ABSENT when the resolver does not know it (the static catalog for a
* definition whose schema is factory-built) — then only the primary-field check applies. */
params?: readonly string[];
nonScalar?: readonly string[];
pathTargeting?: boolean;
}
/** The static catalog's answer for a wire name (undefined ⇒ the catalog does not carry it). */
export declare function catalogRuleFaceView(toolName: string): RuleFaceView | undefined;
/** Exported for the lockstep guard only (the scalar vocabulary of a catalog tool) — not part of the rule DSL. */
export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
/** Dry-run 校验/迁移报告:不 throw,返回全部 issues(含 `unsupported.*` 分类,
* 供从 CC settings 迁移的部署逐条对照)。 */
export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "ruleFaces" | "pathBases">): PermissionRuleIssue[];
export declare function createPermissionRulePolicy(rules: PermissionRule[], opts?: PermissionRulePolicyOptions): NamedToolPolicy;