UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

199 lines (198 loc) • 16.4 kB
/** * 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;