dsh-permission-rules 0.2.0 → 0.4.0
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 +38 -0
- package/README.es.md +11 -6
- package/README.hi.md +11 -6
- package/README.md +11 -6
- package/README.pt.md +11 -6
- package/README.zh.md +11 -6
- package/docs/rules-format.en.md +7 -2
- package/docs/rules-format.md +7 -2
- package/docs/rules-format.schema.json +5 -0
- package/lib/index.js +467 -128
- package/lib/types/config.d.ts +12 -2
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +26 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/events.d.ts +14 -1
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/events.js +5 -0
- package/lib/types/events.js.map +1 -1
- package/lib/types/index.d.ts +8 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +8 -6
- package/lib/types/index.js.map +1 -1
- package/lib/types/prose.d.ts +13 -2
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/prose.js +50 -25
- package/lib/types/prose.js.map +1 -1
- package/lib/types/rules.d.ts +25 -2
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/rules.js +37 -8
- package/lib/types/rules.js.map +1 -1
- package/lib/types/runtime.d.ts +84 -17
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +326 -71
- package/lib/types/runtime.js.map +1 -1
- package/package.json +103 -103
- package/src/config.ts +36 -2
- package/src/events.ts +14 -1
- package/src/index.ts +8 -6
- package/src/prose.ts +63 -27
- package/src/rules.ts +53 -8
- package/src/runtime.ts +320 -67
package/lib/index.js
CHANGED
|
@@ -31,13 +31,16 @@ const Config = z.object({
|
|
|
31
31
|
caseInsensitivePaths: z.boolean().default(process.platform === "win32"),
|
|
32
32
|
audit: z.union(["all", "hits"]).default("all"),
|
|
33
33
|
searchUp: z.boolean().default(false),
|
|
34
|
-
maxGlobStars: z.number().default(2)
|
|
34
|
+
maxGlobStars: z.number().default(2),
|
|
35
|
+
enforce: z.boolean().default(true)
|
|
35
36
|
});
|
|
36
37
|
/**
|
|
37
38
|
* Validate raw values and fill explicit defaults. A `maxRules`,
|
|
38
39
|
* `maxCachedWorkspaces`, or `maxGlobStars` that is not a positive safe
|
|
39
|
-
* integer, a non-positive stability window,
|
|
40
|
-
* absolute `rulesFile
|
|
40
|
+
* integer, a non-positive stability window, `searchUp` combined with an
|
|
41
|
+
* absolute `rulesFile`, a value outside a closed enum, or a non-boolean
|
|
42
|
+
* flag throws here — misconfiguration fails loud at mount even when the
|
|
43
|
+
* plugin is mounted without the Schemastery loader.
|
|
41
44
|
* @param config - raw (possibly partial) plugin config.
|
|
42
45
|
* @returns the fully resolved config.
|
|
43
46
|
*/
|
|
@@ -53,6 +56,20 @@ function resolveConfig(config = {}) {
|
|
|
53
56
|
if (!Number.isSafeInteger(maxCachedWorkspaces) || maxCachedWorkspaces <= 0) throw new TypeError(`maxCachedWorkspaces must be a positive safe integer, got ${String(config.maxCachedWorkspaces)}`);
|
|
54
57
|
const maxGlobStars = config.maxGlobStars ?? 2;
|
|
55
58
|
if (!Number.isSafeInteger(maxGlobStars) || maxGlobStars <= 0) throw new TypeError(`maxGlobStars must be a positive safe integer, got ${String(config.maxGlobStars)}`);
|
|
59
|
+
assertEnum("badFilePolicy", config.badFilePolicy ?? "fail", ["fail", "ignore-with-warning"]);
|
|
60
|
+
assertEnum("patternMode", config.patternMode ?? "glob", ["glob", "regex"]);
|
|
61
|
+
assertEnum("language", config.language ?? "en", [
|
|
62
|
+
"en",
|
|
63
|
+
"zh",
|
|
64
|
+
"es",
|
|
65
|
+
"pt",
|
|
66
|
+
"hi"
|
|
67
|
+
]);
|
|
68
|
+
assertEnum("audit", config.audit ?? "all", ["all", "hits"]);
|
|
69
|
+
assertBoolean("watch", config.watch ?? true);
|
|
70
|
+
assertBoolean("searchUp", searchUp);
|
|
71
|
+
assertBoolean("caseInsensitivePaths", config.caseInsensitivePaths ?? process.platform === "win32");
|
|
72
|
+
assertBoolean("enforce", config.enforce ?? true);
|
|
56
73
|
return {
|
|
57
74
|
rulesFile,
|
|
58
75
|
fallbackPath: config.fallbackPath,
|
|
@@ -66,9 +83,18 @@ function resolveConfig(config = {}) {
|
|
|
66
83
|
caseInsensitivePaths: config.caseInsensitivePaths ?? process.platform === "win32",
|
|
67
84
|
audit: config.audit ?? "all",
|
|
68
85
|
searchUp,
|
|
69
|
-
maxGlobStars
|
|
86
|
+
maxGlobStars,
|
|
87
|
+
enforce: config.enforce ?? true
|
|
70
88
|
};
|
|
71
89
|
}
|
|
90
|
+
/** Throw unless `key` is one of `allowed` (TypeScript's closed enums are not runtime checks). */
|
|
91
|
+
function assertEnum(key, value, allowed) {
|
|
92
|
+
if (!allowed.includes(value)) throw new TypeError(`${key} must be one of ${allowed.map((item) => JSON.stringify(item)).join(" | ")}, got ${JSON.stringify(value)}`);
|
|
93
|
+
}
|
|
94
|
+
/** Throw unless `key` is a boolean (plain-JS mounts bypass the Schemastery coercion). */
|
|
95
|
+
function assertBoolean(key, value) {
|
|
96
|
+
if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean, got ${typeof value}`);
|
|
97
|
+
}
|
|
72
98
|
//#endregion
|
|
73
99
|
//#region src/glob.ts
|
|
74
100
|
/**
|
|
@@ -455,6 +481,7 @@ function compileRulesChain(entries, options) {
|
|
|
455
481
|
/** Compile one parsed rule into its hot-path form. */
|
|
456
482
|
function compileRule(entry, index, sourceIndex, options) {
|
|
457
483
|
const tools = entry.match.tools.map((pattern) => compileToolPattern(pattern, options.maxGlobStars));
|
|
484
|
+
const agents = entry.match.agents.map((pattern) => compileToolPattern(pattern, options.maxGlobStars));
|
|
458
485
|
const params = /* @__PURE__ */ new Map();
|
|
459
486
|
for (const [key, patterns] of Object.entries(entry.match.params)) params.set(key, compileParamPatterns(key, patterns, options));
|
|
460
487
|
const paths = entry.match.paths.map((pattern) => compilePathPattern(pattern, options));
|
|
@@ -469,6 +496,7 @@ function compileRule(entry, index, sourceIndex, options) {
|
|
|
469
496
|
...entry.description !== void 0 ? { description: entry.description } : {},
|
|
470
497
|
tags: entry.tags,
|
|
471
498
|
tools,
|
|
499
|
+
agents,
|
|
472
500
|
params,
|
|
473
501
|
paths,
|
|
474
502
|
absent: entry.match.absent,
|
|
@@ -496,6 +524,7 @@ function matchRules(ruleset, toolName, args, cwd, context = {}) {
|
|
|
496
524
|
for (const rule of ruleset.rules) {
|
|
497
525
|
if (!rule.enabled) continue;
|
|
498
526
|
if (!matchTools(rule, toolName)) continue;
|
|
527
|
+
if (!matchAgents(rule, context)) continue;
|
|
499
528
|
if (!matchWhen(rule, context)) continue;
|
|
500
529
|
if (rule.params.size > 0 && (argRecord === void 0 || !matchParams(rule, argRecord))) continue;
|
|
501
530
|
if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue;
|
|
@@ -529,19 +558,24 @@ function findUnreachableRules(ruleset) {
|
|
|
529
558
|
}
|
|
530
559
|
/** Whether every match dimension of one rule is empty. */
|
|
531
560
|
function isCatchAll(rule) {
|
|
532
|
-
return rule.tools.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === void 0;
|
|
561
|
+
return rule.tools.length === 0 && rule.agents.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === void 0;
|
|
533
562
|
}
|
|
534
563
|
/**
|
|
535
564
|
* One-line human summary of a compiled rule, for `/rules` output. The
|
|
536
565
|
* 1-based number, action, match dimensions, and reason render first; a
|
|
537
|
-
* disabled marker, tags, and a truncated description follow.
|
|
566
|
+
* disabled marker, tags, and a truncated description follow. When `source`
|
|
567
|
+
* is provided (multi-file chains), the rule's origin file is attributed on
|
|
568
|
+
* the line.
|
|
538
569
|
* @param rule - the compiled rule.
|
|
539
570
|
* @param tokens - dimension-prefix vocabulary for the configured language.
|
|
571
|
+
* @param source - display path of the rule's source file, when the listing
|
|
572
|
+
* spans several files.
|
|
540
573
|
* @returns a single display line.
|
|
541
574
|
*/
|
|
542
|
-
function describeRule(rule, tokens) {
|
|
575
|
+
function describeRule(rule, tokens, source) {
|
|
543
576
|
const parts = [];
|
|
544
577
|
if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(",")}`);
|
|
578
|
+
if (rule.source.match.agents.length > 0) parts.push(`${tokens.agents}:${rule.source.match.agents.join(",")}`);
|
|
545
579
|
const params = Object.entries(rule.source.match.params).map(([key, patterns]) => `${key}=${patterns.join("|")}`).join(" ");
|
|
546
580
|
if (params.length > 0) parts.push(`${tokens.params}:${params}`);
|
|
547
581
|
if (rule.source.match.paths.length > 0) parts.push(`${tokens.paths}:${rule.source.match.paths.join(",")}`);
|
|
@@ -551,9 +585,10 @@ function describeRule(rule, tokens) {
|
|
|
551
585
|
if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? " " : ""}${platformPart}`);
|
|
552
586
|
const match = parts.length > 0 ? `[${parts.join(" ")}]` : `[${tokens.allTools}]`;
|
|
553
587
|
const disabled = rule.enabled ? "" : ` (${tokens.disabled})`;
|
|
588
|
+
const sourcePart = source !== void 0 && source.length > 0 ? ` [${tokens.src}:${source}]` : "";
|
|
554
589
|
const description = rule.description !== void 0 ? ` (${truncate(rule.description, DISPLAY_TRUNCATE)})` : "";
|
|
555
590
|
const tags = rule.tags.length > 0 ? ` [${tokens.tags}:${rule.tags.join(",")}]` : "";
|
|
556
|
-
return `${rule.index + 1}. ${rule.action}${disabled} ${match}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`;
|
|
591
|
+
return `${rule.index + 1}. ${rule.action}${disabled} ${match}${sourcePart}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`;
|
|
557
592
|
}
|
|
558
593
|
/** Cut a display string at `limit` characters, marking truncation with `…`. */
|
|
559
594
|
function truncate(text, limit) {
|
|
@@ -564,6 +599,18 @@ function matchTools(rule, toolName) {
|
|
|
564
599
|
if (rule.tools.length === 0) return true;
|
|
565
600
|
return rule.tools.some((regex) => regex.test(toolName));
|
|
566
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Whether a rule's agents dimension selects the caller. Any selector glob
|
|
604
|
+
* matching any identity candidate satisfies the dimension; unknown
|
|
605
|
+
* identity (no agent or no candidates) fails the dimension, so an
|
|
606
|
+
* agent-scoped rule can never fire where the caller cannot be identified.
|
|
607
|
+
*/
|
|
608
|
+
function matchAgents(rule, context) {
|
|
609
|
+
if (rule.agents.length === 0) return true;
|
|
610
|
+
const candidates = context.agents;
|
|
611
|
+
if (candidates === void 0 || candidates.length === 0) return false;
|
|
612
|
+
return rule.agents.some((regex) => candidates.some((candidate) => regex.test(candidate)));
|
|
613
|
+
}
|
|
567
614
|
/** Whether a rule's params dimension holds: EVERY key present and matching. */
|
|
568
615
|
function matchParams(rule, args) {
|
|
569
616
|
for (const [key, { positive, negative }] of rule.params) {
|
|
@@ -675,6 +722,7 @@ function parseRuleEntry(raw, index) {
|
|
|
675
722
|
function parseMatch(raw, at) {
|
|
676
723
|
if (raw === void 0) return {
|
|
677
724
|
tools: [],
|
|
725
|
+
agents: [],
|
|
678
726
|
params: {},
|
|
679
727
|
paths: [],
|
|
680
728
|
absent: [],
|
|
@@ -684,10 +732,11 @@ function parseMatch(raw, at) {
|
|
|
684
732
|
}
|
|
685
733
|
};
|
|
686
734
|
const record = asRecord(raw, `${at}.match`);
|
|
687
|
-
const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when");
|
|
688
|
-
if (unknownFields.length > 0) throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: tools, params, paths, absent, when)`);
|
|
735
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "agents" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when");
|
|
736
|
+
if (unknownFields.length > 0) throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: tools, agents, params, paths, absent, when)`);
|
|
689
737
|
return {
|
|
690
738
|
tools: parseStringList(record["tools"], `${at}.match.tools`),
|
|
739
|
+
agents: parseStringList(record["agents"], `${at}.match.agents`),
|
|
691
740
|
params: parseParams(record["params"], `${at}.match.params`),
|
|
692
741
|
paths: parseStringList(record["paths"], `${at}.match.paths`),
|
|
693
742
|
absent: parseStringList(record["absent"], `${at}.match.absent`),
|
|
@@ -851,7 +900,7 @@ function relFromRoot(root, path, rest, rootRest, caseInsensitive) {
|
|
|
851
900
|
const restLower = caseInsensitive ? rest.toLowerCase() : rest;
|
|
852
901
|
const rootRestLower = caseInsensitive ? rootRest.toLowerCase() : rootRest;
|
|
853
902
|
if (rootRestLower.length === 0) return rest.slice(1);
|
|
854
|
-
if (path.toLowerCase() === root.toLowerCase()) return "";
|
|
903
|
+
if (path === root || caseInsensitive && path.toLowerCase() === root.toLowerCase()) return "";
|
|
855
904
|
return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : "";
|
|
856
905
|
}
|
|
857
906
|
/** Convert a path to posix separators. */
|
|
@@ -880,18 +929,22 @@ const EN = {
|
|
|
880
929
|
reloaded: (count, source) => `Reloaded ${count} rule(s) from ${source}.`,
|
|
881
930
|
reloadFailed: (error) => `Reload failed: ${error}. The previous rules are still active.`,
|
|
882
931
|
lastReloadWarning: (error) => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
|
|
932
|
+
dryRunNotice: "Dry-run mode (enforce: false): deny/ask hits are audit-logged only — every call is passed through.",
|
|
883
933
|
unknownArg: (arg) => `Unknown /rules argument "${arg}". ${EN.usage}`,
|
|
884
|
-
usage: "Usage: /rules [reload | decisions [n] | test <tool> <json-args>]",
|
|
934
|
+
usage: "Usage: /rules [list | reload | decisions [n] | test <tool> <json-args>]",
|
|
885
935
|
decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
|
|
886
936
|
noDecisions: "No permission decisions recorded in this session yet.",
|
|
887
937
|
invalidDecisionsCount: (arg) => `Invalid decisions count "${arg}": give a positive integer (default 10).`,
|
|
888
|
-
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
889
|
-
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (rule ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
938
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
939
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (rule ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? " (dry-run)" : ` (dry-run → ${outcome})` : ""}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
890
940
|
},
|
|
891
941
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" matches rule ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
892
942
|
testNoMatch: (tool) => `"${tool}" matches no rule — the call passes through.`,
|
|
893
943
|
testBadJson: (text) => `Invalid JSON arguments "${text}": the arguments must parse as JSON.`,
|
|
894
|
-
|
|
944
|
+
testUnknownFlag: (flag) => `Unknown /rules test flag "${flag}". ${EN.testUsage}`,
|
|
945
|
+
testBadFlag: (flag) => `Invalid /rules test flag "${flag}": expected a value. ${EN.testUsage}`,
|
|
946
|
+
testBadPlatform: (value) => `Unknown platform "${value}" in --platform: expected one of aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${EN.testUsage}`,
|
|
947
|
+
testUsage: "Usage: /rules test [--cwd <dir>] [--env KEY=VALUE]... [--agent <selector>]... [--platform <name>] <tool> <json-args>, e.g. /rules test bash {\"command\":\"git push origin main\"}",
|
|
895
948
|
unreachableWarning: (numbers) => `Warning: rule${numbers.length > 1 ? "s" : ""} ${numbers.join(", ")} ${numbers.length > 1 ? "are" : "is"} unreachable (shadowed by an earlier catch-all rule).`,
|
|
896
949
|
emptySource: "(no rule file — empty rule set)"
|
|
897
950
|
};
|
|
@@ -902,18 +955,22 @@ const ZH = {
|
|
|
902
955
|
reloaded: (count, source) => `已重载 ${count} 条规则,来自 ${source}。`,
|
|
903
956
|
reloadFailed: (error) => `重载失败:${error}。之前的规则仍然生效。`,
|
|
904
957
|
lastReloadWarning: (error) => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
|
|
958
|
+
dryRunNotice: "干跑模式(enforce: false):deny/ask 命中仅写入审计日志——所有调用照常透传。",
|
|
905
959
|
unknownArg: (arg) => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
|
|
906
|
-
usage: "用法:/rules [reload | decisions [n] | test <工具名> <json-参数>]",
|
|
960
|
+
usage: "用法:/rules [list | reload | decisions [n] | test <工具名> <json-参数>]",
|
|
907
961
|
decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
|
|
908
962
|
noDecisions: "本会话还没有记录任何权限裁决。",
|
|
909
963
|
invalidDecisionsCount: (arg) => `无效的裁决条数 "${arg}":请给正整数(默认 10)。`,
|
|
910
|
-
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
911
|
-
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : `(规则 ${ruleIndex + 1})`}${reason === void 0 ? "" : `:${short(reason)}`}`;
|
|
964
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
965
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : `(规则 ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? "(干跑)" : `(干跑 → ${outcome})` : ""}${reason === void 0 ? "" : `:${short(reason)}`}`;
|
|
912
966
|
},
|
|
913
967
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" 命中规则 ${ruleIndex + 1}(${action}):${short(reason)}`,
|
|
914
968
|
testNoMatch: (tool) => `"${tool}" 未命中任何规则——该调用将透传。`,
|
|
915
969
|
testBadJson: (text) => `无效的 JSON 参数 "${text}":参数必须能被解析为 JSON。`,
|
|
916
|
-
|
|
970
|
+
testUnknownFlag: (flag) => `未知的 /rules test 标志 "${flag}"。${ZH.testUsage}`,
|
|
971
|
+
testBadFlag: (flag) => `无效的 /rules test 标志 "${flag}":缺少取值。${ZH.testUsage}`,
|
|
972
|
+
testBadPlatform: (value) => `未知的平台 "${value}"(--platform):应为 aix、android、darwin、freebsd、linux、openbsd、sunos、win32 之一。${ZH.testUsage}`,
|
|
973
|
+
testUsage: "用法:/rules test [--cwd <目录>] [--env 键=值]... [--agent <选择器>]... [--platform <平台名>] <工具名> <json-参数>,例如 /rules test bash {\"command\":\"git push origin main\"}",
|
|
917
974
|
unreachableWarning: (numbers) => `警告:规则 ${numbers.join("、")} 不可达(被前面的通配规则遮蔽)。`,
|
|
918
975
|
emptySource: "(无规则文件——空规则集)"
|
|
919
976
|
};
|
|
@@ -924,18 +981,22 @@ const ES = {
|
|
|
924
981
|
reloaded: (count, source) => `Recargadas ${count} regla(s) desde ${source}.`,
|
|
925
982
|
reloadFailed: (error) => `Error de recarga: ${error}. Las reglas anteriores siguen activas.`,
|
|
926
983
|
lastReloadWarning: (error) => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
|
|
984
|
+
dryRunNotice: "Modo simulación (enforce: false): los aciertos deny/ask solo se registran en auditoría — todas las llamadas pasan.",
|
|
927
985
|
unknownArg: (arg) => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
|
|
928
|
-
usage: "Uso: /rules [reload | decisions [n] | test <herramienta> <json-args>]",
|
|
986
|
+
usage: "Uso: /rules [list | reload | decisions [n] | test <herramienta> <json-args>]",
|
|
929
987
|
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
|
|
930
988
|
noDecisions: "Esta sesión aún no registra decisiones de permiso.",
|
|
931
989
|
invalidDecisionsCount: (arg) => `Cantidad de decisiones inválida "${arg}": indique un entero positivo (por defecto 10).`,
|
|
932
|
-
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
933
|
-
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (regla ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
990
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
991
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (regla ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? " (simulación)" : ` (simulación → ${outcome})` : ""}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
934
992
|
},
|
|
935
993
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" coincide con la regla ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
936
994
|
testNoMatch: (tool) => `"${tool}" no coincide con ninguna regla — la llamada pasa sin restricciones.`,
|
|
937
995
|
testBadJson: (text) => `Argumentos JSON inválidos "${text}": los argumentos deben poderse analizar como JSON.`,
|
|
938
|
-
|
|
996
|
+
testUnknownFlag: (flag) => `Bandera de /rules test desconocida "${flag}". ${ES.testUsage}`,
|
|
997
|
+
testBadFlag: (flag) => `Bandera de /rules test inválida "${flag}": falta un valor. ${ES.testUsage}`,
|
|
998
|
+
testBadPlatform: (value) => `Plataforma desconocida "${value}" en --platform: se esperaba una de aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${ES.testUsage}`,
|
|
999
|
+
testUsage: "Uso: /rules test [--cwd <dir>] [--env CLAVE=VALOR]... [--agent <selector>]... [--platform <nombre>] <herramienta> <json-args>, p. ej. /rules test bash {\"command\":\"git push origin main\"}",
|
|
939
1000
|
unreachableWarning: (numbers) => `Aviso: ${numbers.length > 1 ? "las reglas" : "la regla"} ${numbers.join(", ")} ${numbers.length > 1 ? "son inalcanzables" : "es inalcanzable"} (tapada por una regla general anterior).`,
|
|
940
1001
|
emptySource: "(sin archivo de reglas — conjunto vacío)"
|
|
941
1002
|
};
|
|
@@ -946,18 +1007,22 @@ const PT = {
|
|
|
946
1007
|
reloaded: (count, source) => `Recarregadas ${count} regra(s) de ${source}.`,
|
|
947
1008
|
reloadFailed: (error) => `Falha ao recarregar: ${error}. As regras anteriores continuam ativas.`,
|
|
948
1009
|
lastReloadWarning: (error) => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
|
|
1010
|
+
dryRunNotice: "Modo simulação (enforce: false): acertos deny/ask são apenas registrados na auditoria — todas as chamadas passam.",
|
|
949
1011
|
unknownArg: (arg) => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
|
|
950
|
-
usage: "Uso: /rules [reload | decisions [n] | test <ferramenta> <json-args>]",
|
|
1012
|
+
usage: "Uso: /rules [list | reload | decisions [n] | test <ferramenta> <json-args>]",
|
|
951
1013
|
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
|
|
952
1014
|
noDecisions: "Esta sessão ainda não registrou decisões de permissão.",
|
|
953
1015
|
invalidDecisionsCount: (arg) => `Quantidade de decisões inválida "${arg}": informe um inteiro positivo (padrão 10).`,
|
|
954
|
-
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
955
|
-
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (regra ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
1016
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
1017
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (regra ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? " (simulação)" : ` (simulação → ${outcome})` : ""}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
956
1018
|
},
|
|
957
1019
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" corresponde à regra ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
958
1020
|
testNoMatch: (tool) => `"${tool}" não corresponde a nenhuma regra — a chamada passa livremente.`,
|
|
959
1021
|
testBadJson: (text) => `Argumentos JSON inválidos "${text}": os argumentos precisam ser JSON analisável.`,
|
|
960
|
-
|
|
1022
|
+
testUnknownFlag: (flag) => `Sinalizador de /rules test desconhecido "${flag}". ${PT.testUsage}`,
|
|
1023
|
+
testBadFlag: (flag) => `Sinalizador de /rules test inválido "${flag}": falta um valor. ${PT.testUsage}`,
|
|
1024
|
+
testBadPlatform: (value) => `Plataforma desconhecida "${value}" em --platform: esperava-se uma de aix, android, darwin, freebsd, linux, openbsd, sunos, win32. ${PT.testUsage}`,
|
|
1025
|
+
testUsage: "Uso: /rules test [--cwd <dir>] [--env CHAVE=VALOR]... [--agent <seletor>]... [--platform <nome>] <ferramenta> <json-args>, ex.: /rules test bash {\"command\":\"git push origin main\"}",
|
|
961
1026
|
unreachableWarning: (numbers) => `Aviso: ${numbers.length > 1 ? "as regras" : "a regra"} ${numbers.join(", ")} ${numbers.length > 1 ? "são inalcançáveis" : "é inalcançável"} (encoberta por uma regra geral anterior).`,
|
|
962
1027
|
emptySource: "(sem arquivo de regras — conjunto vazio)"
|
|
963
1028
|
};
|
|
@@ -968,18 +1033,22 @@ const HI = {
|
|
|
968
1033
|
reloaded: (count, source) => `${count} नियम पुनः लोड किए गए, स्रोत ${source}।`,
|
|
969
1034
|
reloadFailed: (error) => `पुनः लोड विफल: ${error}। पिछले नियम अब भी सक्रिय हैं।`,
|
|
970
1035
|
lastReloadWarning: (error) => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
|
|
1036
|
+
dryRunNotice: "ड्राई-रन मोड (enforce: false): deny/ask हिट केवल ऑडिट में दर्ज होते हैं — हर कॉल पास हो जाती है।",
|
|
971
1037
|
unknownArg: (arg) => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
|
|
972
|
-
usage: "उपयोग: /rules [reload | decisions [n] | test <टूल> <json-args>]",
|
|
1038
|
+
usage: "उपयोग: /rules [list | reload | decisions [n] | test <टूल> <json-args>]",
|
|
973
1039
|
decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
|
|
974
1040
|
noDecisions: "इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।",
|
|
975
1041
|
invalidDecisionsCount: (arg) => `अमान्य निर्णय-संख्या "${arg}": एक धनात्मक पूर्णांक दें (डिफ़ॉल्ट 10)।`,
|
|
976
|
-
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
977
|
-
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (नियम ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
1042
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
1043
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (नियम ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? " (ड्राई-रन)" : ` (ड्राई-रन → ${outcome})` : ""}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
978
1044
|
},
|
|
979
1045
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" नियम ${ruleIndex + 1} से मेल खाता है (${action}): ${short(reason)}`,
|
|
980
1046
|
testNoMatch: (tool) => `"${tool}" किसी नियम से मेल नहीं खाता — कॉल बिना रोक पास होगी।`,
|
|
981
1047
|
testBadJson: (text) => `अमान्य JSON तर्क "${text}": तर्क JSON के रूप में पार्स होने चाहिए।`,
|
|
982
|
-
|
|
1048
|
+
testUnknownFlag: (flag) => `अज्ञात /rules test फ़्लैग "${flag}"। ${HI.testUsage}`,
|
|
1049
|
+
testBadFlag: (flag) => `अमान्य /rules test फ़्लैग "${flag}": मान गायब है। ${HI.testUsage}`,
|
|
1050
|
+
testBadPlatform: (value) => `अज्ञात प्लेटफ़ॉर्म "${value}" (--platform): aix, android, darwin, freebsd, linux, openbsd, sunos, win32 में से एक अपेक्षित। ${HI.testUsage}`,
|
|
1051
|
+
testUsage: "उपयोग: /rules test [--cwd <dir>] [--env KEY=मान]... [--agent <चयनकर्ता>]... [--platform <नाम>] <टूल> <json-args>, जैसे /rules test bash {\"command\":\"git push origin main\"}",
|
|
983
1052
|
unreachableWarning: (numbers) => `चेतावनी: नियम ${numbers.join(", ")} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
|
|
984
1053
|
emptySource: "(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)"
|
|
985
1054
|
};
|
|
@@ -996,57 +1065,67 @@ const DESCRIBE_TOKENS = {
|
|
|
996
1065
|
en: {
|
|
997
1066
|
allTools: "all tools",
|
|
998
1067
|
tools: "tools",
|
|
1068
|
+
agents: "agents",
|
|
999
1069
|
params: "params",
|
|
1000
1070
|
paths: "paths",
|
|
1001
1071
|
absent: "absent",
|
|
1002
1072
|
when: "when",
|
|
1003
1073
|
platform: "platform",
|
|
1004
1074
|
disabled: "disabled",
|
|
1005
|
-
tags: "tags"
|
|
1075
|
+
tags: "tags",
|
|
1076
|
+
src: "src"
|
|
1006
1077
|
},
|
|
1007
1078
|
zh: {
|
|
1008
1079
|
allTools: "全部工具",
|
|
1009
1080
|
tools: "工具",
|
|
1081
|
+
agents: "代理",
|
|
1010
1082
|
params: "参数",
|
|
1011
1083
|
paths: "路径",
|
|
1012
1084
|
absent: "缺省键",
|
|
1013
1085
|
when: "条件",
|
|
1014
1086
|
platform: "平台",
|
|
1015
1087
|
disabled: "已禁用",
|
|
1016
|
-
tags: "标签"
|
|
1088
|
+
tags: "标签",
|
|
1089
|
+
src: "来源"
|
|
1017
1090
|
},
|
|
1018
1091
|
es: {
|
|
1019
1092
|
allTools: "todas las herramientas",
|
|
1020
1093
|
tools: "herramientas",
|
|
1094
|
+
agents: "agentes",
|
|
1021
1095
|
params: "parámetros",
|
|
1022
1096
|
paths: "rutas",
|
|
1023
1097
|
absent: "ausentes",
|
|
1024
1098
|
when: "cuándo",
|
|
1025
1099
|
platform: "plataforma",
|
|
1026
1100
|
disabled: "deshabilitada",
|
|
1027
|
-
tags: "etiquetas"
|
|
1101
|
+
tags: "etiquetas",
|
|
1102
|
+
src: "origen"
|
|
1028
1103
|
},
|
|
1029
1104
|
pt: {
|
|
1030
1105
|
allTools: "todas as ferramentas",
|
|
1031
1106
|
tools: "ferramentas",
|
|
1107
|
+
agents: "agentes",
|
|
1032
1108
|
params: "parâmetros",
|
|
1033
1109
|
paths: "caminhos",
|
|
1034
1110
|
absent: "ausentes",
|
|
1035
1111
|
when: "quando",
|
|
1036
1112
|
platform: "plataforma",
|
|
1037
1113
|
disabled: "desativada",
|
|
1038
|
-
tags: "etiquetas"
|
|
1114
|
+
tags: "etiquetas",
|
|
1115
|
+
src: "origem"
|
|
1039
1116
|
},
|
|
1040
1117
|
hi: {
|
|
1041
1118
|
allTools: "सभी टूल",
|
|
1042
1119
|
tools: "टूल",
|
|
1120
|
+
agents: "एजेंट",
|
|
1043
1121
|
params: "पैरामीटर",
|
|
1044
1122
|
paths: "पथ",
|
|
1045
1123
|
absent: "अनुपस्थित",
|
|
1046
1124
|
when: "शर्त",
|
|
1047
1125
|
platform: "प्लेटफ़ॉर्म",
|
|
1048
1126
|
disabled: "अक्षम",
|
|
1049
|
-
tags: "टैग"
|
|
1127
|
+
tags: "टैग",
|
|
1128
|
+
src: "स्रोत"
|
|
1050
1129
|
}
|
|
1051
1130
|
};
|
|
1052
1131
|
//#endregion
|
|
@@ -1057,8 +1136,10 @@ const DESCRIBE_TOKENS = {
|
|
|
1057
1136
|
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
1058
1137
|
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
1059
1138
|
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
1060
|
-
* (`list | reload | decisions [n] | test <tool> <json>`), and
|
|
1061
|
-
* Chokidar-driven reloads
|
|
1139
|
+
* (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
|
|
1140
|
+
* Chokidar-driven reloads (effective rule files plus candidate watches on
|
|
1141
|
+
* expected-but-absent files, so mid-session creation is adopted). Every
|
|
1142
|
+
* registration is an effect.
|
|
1062
1143
|
* @module dsh-permission-rules/runtime
|
|
1063
1144
|
*/
|
|
1064
1145
|
/**
|
|
@@ -1070,7 +1151,7 @@ var PermissionRulesRuntime = class {
|
|
|
1070
1151
|
config;
|
|
1071
1152
|
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
1072
1153
|
byCwd = /* @__PURE__ */ new Map();
|
|
1073
|
-
/** Live watchers per rule
|
|
1154
|
+
/** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
|
|
1074
1155
|
watchers = /* @__PURE__ */ new Map();
|
|
1075
1156
|
/** Debounce timers per rule-file path. */
|
|
1076
1157
|
timers = /* @__PURE__ */ new Map();
|
|
@@ -1168,6 +1249,17 @@ var PermissionRulesRuntime = class {
|
|
|
1168
1249
|
}
|
|
1169
1250
|
}
|
|
1170
1251
|
/**
|
|
1252
|
+
* Canonical per-workspace cache key: the resolved root, case-folded on
|
|
1253
|
+
* Windows so differently-spelled paths to the same workspace share one
|
|
1254
|
+
* cache entry and one watcher set instead of doubling both.
|
|
1255
|
+
* @param cwd - the session's absolute workspace root.
|
|
1256
|
+
* @returns the canonical key.
|
|
1257
|
+
*/
|
|
1258
|
+
cacheKey(cwd) {
|
|
1259
|
+
const resolved = resolve(cwd);
|
|
1260
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1171
1263
|
* The rules in effect for one cwd, loading on first use. Under
|
|
1172
1264
|
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
1173
1265
|
* pending tool call errors loudly) while the watcher keeps observing the
|
|
@@ -1178,23 +1270,24 @@ var PermissionRulesRuntime = class {
|
|
|
1178
1270
|
* @returns the loaded rules.
|
|
1179
1271
|
*/
|
|
1180
1272
|
rulesFor(cwd) {
|
|
1181
|
-
const
|
|
1273
|
+
const key = this.cacheKey(cwd);
|
|
1274
|
+
const existing = this.byCwd.get(key);
|
|
1182
1275
|
if (existing !== void 0) {
|
|
1183
1276
|
if (existing.failed === true) throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`);
|
|
1184
|
-
this.byCwd.delete(
|
|
1185
|
-
this.byCwd.set(
|
|
1277
|
+
this.byCwd.delete(key);
|
|
1278
|
+
this.byCwd.set(key, existing);
|
|
1186
1279
|
return existing;
|
|
1187
1280
|
}
|
|
1188
|
-
this.evictIfFull(
|
|
1281
|
+
this.evictIfFull(key);
|
|
1189
1282
|
try {
|
|
1190
1283
|
const loaded = this.load(cwd);
|
|
1191
|
-
this.byCwd.set(
|
|
1192
|
-
this.reconcileWatch(
|
|
1284
|
+
this.byCwd.set(key, loaded);
|
|
1285
|
+
this.reconcileWatch(key, loaded.sources);
|
|
1193
1286
|
return loaded;
|
|
1194
1287
|
} catch (error) {
|
|
1195
1288
|
const message = error instanceof Error ? error.message : String(error);
|
|
1196
1289
|
const sources = this.resolveSources(cwd);
|
|
1197
|
-
this.byCwd.set(
|
|
1290
|
+
this.byCwd.set(key, {
|
|
1198
1291
|
cwd,
|
|
1199
1292
|
sources,
|
|
1200
1293
|
compiled: {
|
|
@@ -1204,7 +1297,7 @@ var PermissionRulesRuntime = class {
|
|
|
1204
1297
|
lastError: message,
|
|
1205
1298
|
failed: true
|
|
1206
1299
|
});
|
|
1207
|
-
this.reconcileWatch(
|
|
1300
|
+
this.reconcileWatch(key, sources);
|
|
1208
1301
|
throw error;
|
|
1209
1302
|
}
|
|
1210
1303
|
}
|
|
@@ -1216,44 +1309,59 @@ var PermissionRulesRuntime = class {
|
|
|
1216
1309
|
* @param cwd - the workspace root.
|
|
1217
1310
|
*/
|
|
1218
1311
|
reload(cwd) {
|
|
1219
|
-
const
|
|
1312
|
+
const key = this.cacheKey(cwd);
|
|
1313
|
+
const previous = this.byCwd.get(key);
|
|
1220
1314
|
try {
|
|
1221
1315
|
const loaded = this.load(cwd);
|
|
1222
|
-
this.byCwd.set(
|
|
1223
|
-
this.reconcileWatch(
|
|
1316
|
+
this.byCwd.set(key, loaded);
|
|
1317
|
+
this.reconcileWatch(key, loaded.sources);
|
|
1224
1318
|
this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(", ") || "(empty rule set)"} for ${cwd}`);
|
|
1225
1319
|
} catch (error) {
|
|
1226
1320
|
const message = error instanceof Error ? error.message : String(error);
|
|
1227
1321
|
this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`);
|
|
1228
|
-
if (previous !== void 0) this.byCwd.set(
|
|
1322
|
+
if (previous !== void 0) this.byCwd.set(key, {
|
|
1229
1323
|
...previous,
|
|
1230
1324
|
lastError: message
|
|
1231
1325
|
});
|
|
1232
|
-
else
|
|
1233
|
-
cwd
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1326
|
+
else {
|
|
1327
|
+
const sources = this.resolveSources(cwd);
|
|
1328
|
+
this.byCwd.set(key, {
|
|
1329
|
+
cwd,
|
|
1330
|
+
sources,
|
|
1331
|
+
compiled: {
|
|
1332
|
+
rules: [],
|
|
1333
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths
|
|
1334
|
+
},
|
|
1335
|
+
lastError: message,
|
|
1336
|
+
failed: true
|
|
1337
|
+
});
|
|
1338
|
+
this.reconcileWatch(key, sources);
|
|
1339
|
+
}
|
|
1242
1340
|
}
|
|
1243
1341
|
}
|
|
1244
|
-
/**
|
|
1245
|
-
|
|
1342
|
+
/**
|
|
1343
|
+
* The match context `when`/`agents` conditions evaluate against: host
|
|
1344
|
+
* facts plus the caller's agent-identity candidates derived from the
|
|
1345
|
+
* session header (`main` for top-level sessions, `subagent` for
|
|
1346
|
+
* subagent children, `preset:<name>` when a preset composed the agent).
|
|
1347
|
+
* @param exec - the pending call whose caller supplies the identity.
|
|
1348
|
+
*/
|
|
1349
|
+
matchContext(exec) {
|
|
1246
1350
|
return {
|
|
1247
1351
|
platform: process.platform,
|
|
1248
|
-
env: process.env
|
|
1352
|
+
env: process.env,
|
|
1353
|
+
agents: agentCandidates(exec?.agent)
|
|
1249
1354
|
};
|
|
1250
1355
|
}
|
|
1251
1356
|
/**
|
|
1252
1357
|
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
1253
1358
|
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
1254
1359
|
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
1255
|
-
* their say.
|
|
1256
|
-
*
|
|
1360
|
+
* their say. Under `enforce: false` (dry-run) a deny/ask hit also
|
|
1361
|
+
* delegates — the record keeps the would-be action with `dryRun: true`
|
|
1362
|
+
* and the actual downstream outcome. Audit is appended once the final
|
|
1363
|
+
* outcome is known, so the recorded `outcome` matches what the waterfall
|
|
1364
|
+
* settled on.
|
|
1257
1365
|
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
1258
1366
|
* @param next - the downstream chain.
|
|
1259
1367
|
* @returns the pre-execute decision.
|
|
@@ -1261,12 +1369,17 @@ var PermissionRulesRuntime = class {
|
|
|
1261
1369
|
async preExecute(exec, next) {
|
|
1262
1370
|
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
1263
1371
|
const loaded = this.rulesFor(cwd);
|
|
1264
|
-
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext());
|
|
1372
|
+
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec));
|
|
1265
1373
|
if (hit === void 0 || hit.rule.action === "allow") {
|
|
1266
1374
|
const decision = await next();
|
|
1267
1375
|
this.audit(exec, loaded, hit, decision.kind);
|
|
1268
1376
|
return decision;
|
|
1269
1377
|
}
|
|
1378
|
+
if (!this.config.enforce) {
|
|
1379
|
+
const decision = await next();
|
|
1380
|
+
this.audit(exec, loaded, hit, decision.kind, true);
|
|
1381
|
+
return decision;
|
|
1382
|
+
}
|
|
1270
1383
|
const outcome = hit.rule.action;
|
|
1271
1384
|
this.audit(exec, loaded, hit, outcome);
|
|
1272
1385
|
if (outcome === "deny") return {
|
|
@@ -1284,15 +1397,18 @@ var PermissionRulesRuntime = class {
|
|
|
1284
1397
|
* envelope's `ignorable: true` marker so any harness build can load the
|
|
1285
1398
|
* log (readers that do not know the type skip the audit record instead of
|
|
1286
1399
|
* refusing the session). `source` names the matched rule's file, or the
|
|
1287
|
-
* nearest effective file on a passthrough
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1400
|
+
* nearest effective file on a passthrough; `cwd` names the workspace the
|
|
1401
|
+
* rules were resolved for; `dryRun` marks would-be deny/ask hits under
|
|
1402
|
+
* `enforce: false`. Agentless calls have no session to audit; append
|
|
1403
|
+
* failures are contained so an audit hiccup can never change a
|
|
1404
|
+
* permission decision.
|
|
1290
1405
|
* @param exec - the pending call.
|
|
1291
1406
|
* @param loaded - the rules in effect.
|
|
1292
1407
|
* @param hit - the first matching rule, or undefined for passthrough.
|
|
1293
1408
|
* @param outcome - the final pre-execute decision.
|
|
1409
|
+
* @param dryRun - mark the record as a would-be decision (dry-run mode).
|
|
1294
1410
|
*/
|
|
1295
|
-
audit(exec, loaded, hit, outcome) {
|
|
1411
|
+
audit(exec, loaded, hit, outcome, dryRun = false) {
|
|
1296
1412
|
if (this.config.audit === "hits" && hit === void 0) return;
|
|
1297
1413
|
const agent = exec.agent;
|
|
1298
1414
|
if (agent === void 0) return;
|
|
@@ -1303,10 +1419,12 @@ var PermissionRulesRuntime = class {
|
|
|
1303
1419
|
source: hit === void 0 ? loaded.sources[0] ?? "" : loaded.sources[hit.rule.sourceIndex] ?? "",
|
|
1304
1420
|
action: hit === void 0 ? "passthrough" : hit.rule.action,
|
|
1305
1421
|
outcome,
|
|
1422
|
+
cwd: loaded.cwd,
|
|
1306
1423
|
...hit !== void 0 ? {
|
|
1307
1424
|
ruleIndex: hit.ruleIndex,
|
|
1308
1425
|
reason: hit.rule.reason
|
|
1309
|
-
} : {}
|
|
1426
|
+
} : {},
|
|
1427
|
+
...dryRun ? { dryRun: true } : {}
|
|
1310
1428
|
}, { ignorable: true });
|
|
1311
1429
|
} catch (error) {
|
|
1312
1430
|
this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`);
|
|
@@ -1315,8 +1433,9 @@ var PermissionRulesRuntime = class {
|
|
|
1315
1433
|
/**
|
|
1316
1434
|
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
1317
1435
|
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
1318
|
-
* [n]` shows the session's audit trail; `/rules test <tool>
|
|
1319
|
-
* dry-evaluates the rules against a hypothetical call
|
|
1436
|
+
* [n]` shows the session's audit trail; `/rules test [flags] <tool>
|
|
1437
|
+
* <json>` dry-evaluates the rules against a hypothetical call (flags
|
|
1438
|
+
* override the workspace, host env, and agent identity). Command output
|
|
1320
1439
|
* stays in the UI — nothing here is injected into the model context.
|
|
1321
1440
|
* @param invocation - the received command invocation.
|
|
1322
1441
|
* @returns the command result shown to the user.
|
|
@@ -1333,7 +1452,7 @@ var PermissionRulesRuntime = class {
|
|
|
1333
1452
|
text: prose.unknownArg(invocation.rawInput.trim())
|
|
1334
1453
|
};
|
|
1335
1454
|
this.reload(cwd);
|
|
1336
|
-
const reloaded = this.byCwd.get(cwd);
|
|
1455
|
+
const reloaded = this.byCwd.get(this.cacheKey(cwd));
|
|
1337
1456
|
if (reloaded?.lastError !== void 0) return {
|
|
1338
1457
|
kind: "error",
|
|
1339
1458
|
text: prose.reloadFailed(reloaded.lastError)
|
|
@@ -1345,6 +1464,10 @@ var PermissionRulesRuntime = class {
|
|
|
1345
1464
|
text: prose.reloaded(rules.length, source)
|
|
1346
1465
|
};
|
|
1347
1466
|
}
|
|
1467
|
+
if (verb === "list" && rest.length > 0) return {
|
|
1468
|
+
kind: "error",
|
|
1469
|
+
text: prose.unknownArg(invocation.rawInput.trim())
|
|
1470
|
+
};
|
|
1348
1471
|
if (verb === "decisions") {
|
|
1349
1472
|
if (rest.length > 1) return {
|
|
1350
1473
|
kind: "error",
|
|
@@ -1361,41 +1484,8 @@ var PermissionRulesRuntime = class {
|
|
|
1361
1484
|
}
|
|
1362
1485
|
return this.decisionsCommand(invocation, count, prose);
|
|
1363
1486
|
}
|
|
1364
|
-
if (verb === "test")
|
|
1365
|
-
|
|
1366
|
-
const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : "";
|
|
1367
|
-
const match = /^(\S+)\s*(.*)$/s.exec(restLine);
|
|
1368
|
-
const tool = match?.[1];
|
|
1369
|
-
const jsonText = match?.[2] ?? "";
|
|
1370
|
-
if (tool === void 0 || tool.length === 0) return {
|
|
1371
|
-
kind: "error",
|
|
1372
|
-
text: prose.testUsage
|
|
1373
|
-
};
|
|
1374
|
-
let args;
|
|
1375
|
-
try {
|
|
1376
|
-
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
1377
|
-
} catch {
|
|
1378
|
-
return {
|
|
1379
|
-
kind: "error",
|
|
1380
|
-
text: prose.testBadJson(jsonText)
|
|
1381
|
-
};
|
|
1382
|
-
}
|
|
1383
|
-
let loaded;
|
|
1384
|
-
try {
|
|
1385
|
-
loaded = this.rulesFor(cwd);
|
|
1386
|
-
} catch (error) {
|
|
1387
|
-
return {
|
|
1388
|
-
kind: "error",
|
|
1389
|
-
text: error instanceof Error ? error.message : String(error)
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext());
|
|
1393
|
-
return {
|
|
1394
|
-
kind: "success",
|
|
1395
|
-
text: hit === void 0 ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason)
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
if (verb !== "") return {
|
|
1487
|
+
if (verb === "test") return this.testCommand(raw, verbRaw ?? "", invocation, cwd, prose);
|
|
1488
|
+
if (verb !== "" && verb !== "list") return {
|
|
1399
1489
|
kind: "error",
|
|
1400
1490
|
text: prose.unknownArg(invocation.rawInput.trim())
|
|
1401
1491
|
};
|
|
@@ -1412,17 +1502,116 @@ var PermissionRulesRuntime = class {
|
|
|
1412
1502
|
if (loaded.sources.length === 0) lines.push(prose.noRules(cwd, this.config.fallbackPath !== void 0 ? prose.fallbackMissing : ""));
|
|
1413
1503
|
else {
|
|
1414
1504
|
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd));
|
|
1415
|
-
|
|
1505
|
+
const multiSource = loaded.sources.length > 1;
|
|
1506
|
+
lines.push(...loaded.compiled.rules.map((rule) => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? "", cwd) : void 0)));
|
|
1416
1507
|
const unreachable = findUnreachableRules(loaded.compiled);
|
|
1417
1508
|
if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map((index) => index + 1)));
|
|
1418
1509
|
}
|
|
1419
1510
|
if (loaded.lastError !== void 0) lines.push(prose.lastReloadWarning(loaded.lastError));
|
|
1511
|
+
if (!this.config.enforce) lines.push(prose.dryRunNotice);
|
|
1420
1512
|
lines.push(prose.usage);
|
|
1421
1513
|
return {
|
|
1422
1514
|
kind: "success",
|
|
1423
1515
|
text: lines.join("\n")
|
|
1424
1516
|
};
|
|
1425
1517
|
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Execute `/rules test` with optional leading flags: `--cwd <dir>`
|
|
1520
|
+
* evaluates against that workspace (rule discovery AND path
|
|
1521
|
+
* normalization), `--env KEY=VALUE` (repeatable) overrides host env for
|
|
1522
|
+
* `when.env` matching, `--agent <selector>` (repeatable) supplies
|
|
1523
|
+
* agent-identity candidates for the `agents` dimension, and
|
|
1524
|
+
* `--platform <name>` overrides the host platform for `when.platform`.
|
|
1525
|
+
* The JSON argument tail is kept verbatim, so quoted JSON survives
|
|
1526
|
+
* unchanged.
|
|
1527
|
+
* @param raw - the full raw command input.
|
|
1528
|
+
* @param verbRaw - the verb as typed.
|
|
1529
|
+
* @param invocation - the received command invocation (session agent).
|
|
1530
|
+
* @param sessionCwd - the session's workspace root.
|
|
1531
|
+
* @param prose - localized output vocabulary.
|
|
1532
|
+
* @returns the dry-evaluation result.
|
|
1533
|
+
*/
|
|
1534
|
+
testCommand(raw, verbRaw, invocation, sessionCwd, prose) {
|
|
1535
|
+
let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : "";
|
|
1536
|
+
const envOverrides = {};
|
|
1537
|
+
const agentSelectors = [];
|
|
1538
|
+
let testCwd;
|
|
1539
|
+
let testPlatform;
|
|
1540
|
+
let parsed = nextToken(rest);
|
|
1541
|
+
while (parsed !== void 0 && parsed.token.startsWith("--")) {
|
|
1542
|
+
const flag = parsed.token;
|
|
1543
|
+
const value = nextToken(parsed.rest);
|
|
1544
|
+
if (value === void 0 || value.token.startsWith("--")) return {
|
|
1545
|
+
kind: "error",
|
|
1546
|
+
text: prose.testBadFlag(flag)
|
|
1547
|
+
};
|
|
1548
|
+
if (flag === "--cwd") testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token);
|
|
1549
|
+
else if (flag === "--env") {
|
|
1550
|
+
const equals = value.token.indexOf("=");
|
|
1551
|
+
if (equals <= 0) return {
|
|
1552
|
+
kind: "error",
|
|
1553
|
+
text: prose.testBadFlag(`${flag} ${value.token}`)
|
|
1554
|
+
};
|
|
1555
|
+
envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1);
|
|
1556
|
+
} else if (flag === "--agent") agentSelectors.push(value.token);
|
|
1557
|
+
else if (flag === "--platform") {
|
|
1558
|
+
if (!PLATFORMS.includes(value.token)) return {
|
|
1559
|
+
kind: "error",
|
|
1560
|
+
text: prose.testBadPlatform(value.token)
|
|
1561
|
+
};
|
|
1562
|
+
testPlatform = value.token;
|
|
1563
|
+
} else return {
|
|
1564
|
+
kind: "error",
|
|
1565
|
+
text: prose.testUnknownFlag(flag)
|
|
1566
|
+
};
|
|
1567
|
+
rest = value.rest;
|
|
1568
|
+
parsed = nextToken(rest);
|
|
1569
|
+
}
|
|
1570
|
+
const tool = parsed?.token;
|
|
1571
|
+
const jsonText = parsed === void 0 ? "" : parsed.rest.trim();
|
|
1572
|
+
if (tool === void 0 || tool.length === 0) return {
|
|
1573
|
+
kind: "error",
|
|
1574
|
+
text: prose.testUsage
|
|
1575
|
+
};
|
|
1576
|
+
let args;
|
|
1577
|
+
try {
|
|
1578
|
+
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
1579
|
+
} catch {
|
|
1580
|
+
return {
|
|
1581
|
+
kind: "error",
|
|
1582
|
+
text: prose.testBadJson(jsonText)
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
const evalCwd = testCwd ?? sessionCwd;
|
|
1586
|
+
let loaded;
|
|
1587
|
+
try {
|
|
1588
|
+
loaded = this.rulesFor(evalCwd);
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
return {
|
|
1591
|
+
kind: "error",
|
|
1592
|
+
text: error instanceof Error ? error.message : String(error)
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
const context = {
|
|
1596
|
+
platform: testPlatform ?? process.platform,
|
|
1597
|
+
env: Object.keys(envOverrides).length === 0 ? process.env : {
|
|
1598
|
+
...process.env,
|
|
1599
|
+
...envOverrides
|
|
1600
|
+
},
|
|
1601
|
+
agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent)
|
|
1602
|
+
};
|
|
1603
|
+
const hit = matchRules(loaded.compiled, tool, args, evalCwd, context);
|
|
1604
|
+
return {
|
|
1605
|
+
kind: "success",
|
|
1606
|
+
text: hit === void 0 ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason)
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
/** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
|
|
1610
|
+
displaySource(source, cwd) {
|
|
1611
|
+
if (source.length === 0) return "";
|
|
1612
|
+
const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths);
|
|
1613
|
+
return relative.length > 0 ? relative : source;
|
|
1614
|
+
}
|
|
1426
1615
|
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
1427
1616
|
decisionsCommand(invocation, count, prose) {
|
|
1428
1617
|
const decisions = invocation.agent.session.events.filter((event) => event.type === "permissionRules/decision");
|
|
@@ -1434,7 +1623,7 @@ var PermissionRulesRuntime = class {
|
|
|
1434
1623
|
const lines = [prose.decisionsHeader(shown.length, decisions.length)];
|
|
1435
1624
|
for (const event of shown) {
|
|
1436
1625
|
const data = event.data;
|
|
1437
|
-
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason));
|
|
1626
|
+
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome));
|
|
1438
1627
|
}
|
|
1439
1628
|
return {
|
|
1440
1629
|
kind: "success",
|
|
@@ -1507,16 +1696,97 @@ var PermissionRulesRuntime = class {
|
|
|
1507
1696
|
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
1508
1697
|
* left the chain (deleted file, fallback switch), detach the workspace
|
|
1509
1698
|
* from watchers serving other sources and close the ones left empty, so
|
|
1510
|
-
* long-running hosts cannot accumulate stale watchers.
|
|
1511
|
-
* rule
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1699
|
+
* long-running hosts cannot accumulate stale watchers. Expected-but-absent
|
|
1700
|
+
* rule files (the project file when it is not effective, and a configured
|
|
1701
|
+
* fallback that does not exist) are covered by candidate watchers on
|
|
1702
|
+
* their deepest existing ancestor directory, so a file created
|
|
1703
|
+
* mid-session is adopted without a manual `/rules reload`. With
|
|
1704
|
+
* `searchUp`, only the immediate cwd-level candidate is watched —
|
|
1705
|
+
* deeper ancestors are discovered on the next load.
|
|
1514
1706
|
*/
|
|
1515
1707
|
reconcileWatch(cwd, sources) {
|
|
1516
|
-
if (sources.
|
|
1517
|
-
|
|
1708
|
+
for (const [watchedSource, entry] of this.watchers) if (!sources.includes(watchedSource)) {
|
|
1709
|
+
entry.cwds.delete(cwd);
|
|
1710
|
+
entry.candidates?.delete(cwd);
|
|
1711
|
+
}
|
|
1518
1712
|
this.pruneWatchers();
|
|
1519
1713
|
for (const source of sources) this.attachWatch(cwd, source);
|
|
1714
|
+
for (const candidate of this.candidateSources(cwd)) {
|
|
1715
|
+
if (sources.includes(candidate) || existsSync(candidate)) continue;
|
|
1716
|
+
this.attachCandidateWatch(cwd, candidate);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* The rule-file paths a workspace COULD be served by: the project file
|
|
1721
|
+
* (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
|
|
1722
|
+
* only under `searchUp`) plus the configured fallback.
|
|
1723
|
+
*/
|
|
1724
|
+
candidateSources(cwd) {
|
|
1725
|
+
const candidates = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)];
|
|
1726
|
+
const fallback = this.config.fallbackPath;
|
|
1727
|
+
if (fallback !== void 0) candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback));
|
|
1728
|
+
return candidates;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Watch one expected-but-absent rule file through its deepest existing
|
|
1732
|
+
* ancestor directory. Chokidar cannot reliably watch a missing path when
|
|
1733
|
+
* its parent is also missing, but directory watching is dependable; every
|
|
1734
|
+
* relevant event re-checks existence and only triggers a reload once the
|
|
1735
|
+
* candidate actually appeared, so unrelated workspace activity while the
|
|
1736
|
+
* file is absent costs a stat, not a reload.
|
|
1737
|
+
*/
|
|
1738
|
+
attachCandidateWatch(cwd, candidate) {
|
|
1739
|
+
if (!this.config.watch) return;
|
|
1740
|
+
const dir = this.deepestExistingDir(dirname(candidate));
|
|
1741
|
+
const existing = this.watchers.get(dir);
|
|
1742
|
+
if (existing !== void 0) {
|
|
1743
|
+
existing.cwds.add(cwd);
|
|
1744
|
+
existing.candidates?.set(cwd, candidate);
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
const cwds = /* @__PURE__ */ new Set([cwd]);
|
|
1748
|
+
const candidates = /* @__PURE__ */ new Map([[cwd, candidate]]);
|
|
1749
|
+
const watcher = chokidar.watch(dir, {
|
|
1750
|
+
persistent: true,
|
|
1751
|
+
ignoreInitial: true
|
|
1752
|
+
});
|
|
1753
|
+
const onEvent = () => this.scheduleReload(dir);
|
|
1754
|
+
watcher.on("add", onEvent);
|
|
1755
|
+
watcher.on("addDir", onEvent);
|
|
1756
|
+
watcher.on("change", onEvent);
|
|
1757
|
+
watcher.on("unlink", onEvent);
|
|
1758
|
+
watcher.on("unlinkDir", onEvent);
|
|
1759
|
+
watcher.on("error", (error) => {
|
|
1760
|
+
this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`);
|
|
1761
|
+
});
|
|
1762
|
+
this.watchers.set(dir, {
|
|
1763
|
+
cwds,
|
|
1764
|
+
candidates,
|
|
1765
|
+
close: () => {
|
|
1766
|
+
watcher.close().catch((error) => {
|
|
1767
|
+
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`);
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
this.ctx.effect(() => () => {
|
|
1772
|
+
this.watchers.get(dir)?.close();
|
|
1773
|
+
this.watchers.delete(dir);
|
|
1774
|
+
const timer = this.timers.get(dir);
|
|
1775
|
+
if (timer !== void 0) {
|
|
1776
|
+
clearTimeout(timer);
|
|
1777
|
+
this.timers.delete(dir);
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
/** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
|
|
1782
|
+
deepestExistingDir(dir) {
|
|
1783
|
+
let current = dir;
|
|
1784
|
+
for (;;) {
|
|
1785
|
+
if (existsSync(current)) return current;
|
|
1786
|
+
const parent = dirname(current);
|
|
1787
|
+
if (parent === current) return current;
|
|
1788
|
+
current = parent;
|
|
1789
|
+
}
|
|
1520
1790
|
}
|
|
1521
1791
|
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
1522
1792
|
pruneWatchers() {
|
|
@@ -1543,7 +1813,10 @@ var PermissionRulesRuntime = class {
|
|
|
1543
1813
|
const oldest = this.byCwd.keys().next().value;
|
|
1544
1814
|
if (oldest === void 0) return;
|
|
1545
1815
|
this.byCwd.delete(oldest);
|
|
1546
|
-
for (const entry of this.watchers.values())
|
|
1816
|
+
for (const entry of this.watchers.values()) {
|
|
1817
|
+
entry.cwds.delete(oldest);
|
|
1818
|
+
entry.candidates?.delete(oldest);
|
|
1819
|
+
}
|
|
1547
1820
|
this.pruneWatchers();
|
|
1548
1821
|
}
|
|
1549
1822
|
/** Number of live watchers, for observability and tests. */
|
|
@@ -1554,13 +1827,24 @@ var PermissionRulesRuntime = class {
|
|
|
1554
1827
|
pendingReloadCount() {
|
|
1555
1828
|
return this.timers.size;
|
|
1556
1829
|
}
|
|
1557
|
-
/**
|
|
1830
|
+
/**
|
|
1831
|
+
* Debounce watch events into one reload per stability window. A
|
|
1832
|
+
* candidate (directory) watcher only reloads a cwd once its expected
|
|
1833
|
+
* file actually exists — unrelated events while the file is absent are
|
|
1834
|
+
* dropped before any re-read.
|
|
1835
|
+
*/
|
|
1558
1836
|
scheduleReload(source) {
|
|
1559
1837
|
const existing = this.timers.get(source);
|
|
1560
1838
|
if (existing !== void 0) clearTimeout(existing);
|
|
1561
1839
|
const timer = setTimeout(() => {
|
|
1562
1840
|
this.timers.delete(source);
|
|
1563
|
-
|
|
1841
|
+
const entry = this.watchers.get(source);
|
|
1842
|
+
if (entry === void 0) return;
|
|
1843
|
+
for (const cwd of entry.cwds) {
|
|
1844
|
+
const candidate = entry.candidates?.get(cwd);
|
|
1845
|
+
if (candidate !== void 0 && !existsSync(candidate)) continue;
|
|
1846
|
+
this.reload(cwd);
|
|
1847
|
+
}
|
|
1564
1848
|
}, this.config.watchStabilityThresholdMs);
|
|
1565
1849
|
this.timers.set(source, timer);
|
|
1566
1850
|
}
|
|
@@ -1579,21 +1863,76 @@ function apply(ctx, config) {
|
|
|
1579
1863
|
ctx.commands.register({
|
|
1580
1864
|
name: "rules",
|
|
1581
1865
|
description: "list, reload, audit, or dry-test the active permission rules for this workspace",
|
|
1582
|
-
input: { hint: "[reload | decisions [n] | test <tool> <json-args>]" },
|
|
1866
|
+
input: { hint: "[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]" },
|
|
1583
1867
|
handler: (invocation) => runtime.command(invocation)
|
|
1584
1868
|
});
|
|
1585
1869
|
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Identity candidates for the `agents` match dimension, derived from the
|
|
1872
|
+
* caller agent's session header: `main` for top-level sessions, `subagent`
|
|
1873
|
+
* for subagent children (`header.origin === 'subagent'`), and
|
|
1874
|
+
* `preset:<name>` when a preset composed the agent. No agent (or an
|
|
1875
|
+
* unidentifiable one) yields no candidates, so agent-scoped rules fail
|
|
1876
|
+
* closed instead of matching an unknown caller.
|
|
1877
|
+
* @param agent - the calling agent, when the call has one.
|
|
1878
|
+
* @returns the candidate strings, in a stable order.
|
|
1879
|
+
*/
|
|
1880
|
+
function agentCandidates(agent) {
|
|
1881
|
+
if (agent === void 0) return [];
|
|
1882
|
+
const header = agent.session.header;
|
|
1883
|
+
const candidates = [header.origin === "subagent" ? "subagent" : "main"];
|
|
1884
|
+
if (typeof header.agentPreset === "string" && header.agentPreset.length > 0) candidates.push(`preset:${header.agentPreset}`);
|
|
1885
|
+
return candidates;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Pull the next whitespace-delimited argument from a command tail,
|
|
1889
|
+
* honoring single/double quotes (with backslash escapes) so JSON blobs and
|
|
1890
|
+
* paths with spaces survive. The remainder is returned verbatim — the JSON
|
|
1891
|
+
* argument tail of `/rules test` is never re-tokenized.
|
|
1892
|
+
* @param text - the remaining command tail.
|
|
1893
|
+
* @returns the token plus the untouched remainder, or undefined when only
|
|
1894
|
+
* whitespace remains.
|
|
1895
|
+
*/
|
|
1896
|
+
function nextToken(text) {
|
|
1897
|
+
let i = 0;
|
|
1898
|
+
while (i < text.length && /\s/.test(text[i])) i += 1;
|
|
1899
|
+
if (i >= text.length) return void 0;
|
|
1900
|
+
const quote = text[i];
|
|
1901
|
+
if (quote === "\"" || quote === "'") {
|
|
1902
|
+
let end = i + 1;
|
|
1903
|
+
while (end < text.length && text[end] !== quote) {
|
|
1904
|
+
if (text[end] === "\\") end += 1;
|
|
1905
|
+
end += 1;
|
|
1906
|
+
}
|
|
1907
|
+
if (end >= text.length) return {
|
|
1908
|
+
token: text.slice(i + 1),
|
|
1909
|
+
rest: ""
|
|
1910
|
+
};
|
|
1911
|
+
return {
|
|
1912
|
+
token: text.slice(i + 1, end),
|
|
1913
|
+
rest: text.slice(end + 1)
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
let end = i;
|
|
1917
|
+
while (end < text.length && !/\s/.test(text[end])) end += 1;
|
|
1918
|
+
return {
|
|
1919
|
+
token: text.slice(i, end),
|
|
1920
|
+
rest: text.slice(end)
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1586
1923
|
//#endregion
|
|
1587
1924
|
//#region src/index.ts
|
|
1588
1925
|
/**
|
|
1589
1926
|
* `dsh-permission-rules` — Claude Code-style declarative permission rules
|
|
1590
1927
|
* for DeepSeek Harness. A `tools/pre-execute` waterfall listener evaluates
|
|
1591
|
-
* an ordered `allow`/`deny`/`ask` rule list (tool-name globs,
|
|
1592
|
-
* glob/regex matching, workspace-path matching) per
|
|
1593
|
-
* ask decisions short-circuit the chain, while allow
|
|
1594
|
-
* delegate via `next()`. `
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1928
|
+
* an ordered `allow`/`deny`/`ask` rule list (tool-name globs, agent
|
|
1929
|
+
* selectors, argument glob/regex matching, workspace-path matching) per
|
|
1930
|
+
* session cwd; deny and ask decisions short-circuit the chain, while allow
|
|
1931
|
+
* and passthrough always delegate via `next()`. Under `enforce: false` the
|
|
1932
|
+
* plugin audits what it WOULD decide and lets everything through (dry-run
|
|
1933
|
+
* policy rollout). `ask` rides the official approval seam, where answerers
|
|
1934
|
+
* such as `dsh-auto-review` or a human UI decide — the plugin itself never
|
|
1935
|
+
* runs a reviewer. Every decision is audit-logged.
|
|
1597
1936
|
*
|
|
1598
1937
|
* Function plugin — no default export (the Loader unwraps
|
|
1599
1938
|
* `exports.default ?? exports`, and a stray default would discard
|