dsh-permission-rules 0.2.0 → 0.3.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 +21 -0
- package/README.es.md +8 -4
- package/README.hi.md +8 -4
- package/README.md +8 -4
- package/README.pt.md +8 -4
- package/README.zh.md +8 -4
- package/docs/rules-format.en.md +4 -1
- package/docs/rules-format.md +4 -1
- package/docs/rules-format.schema.json +5 -0
- package/lib/index.js +269 -79
- 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 +9 -2
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/prose.js +40 -20
- package/lib/types/prose.js.map +1 -1
- package/lib/types/rules.d.ts +17 -0
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/rules.js +29 -5
- package/lib/types/rules.js.map +1 -1
- package/lib/types/runtime.d.ts +38 -10
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +165 -41
- 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 +49 -22
- package/src/rules.ts +43 -5
- package/src/runtime.ts +154 -38
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,7 +558,7 @@ 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
|
|
@@ -542,6 +571,7 @@ function isCatchAll(rule) {
|
|
|
542
571
|
function describeRule(rule, tokens) {
|
|
543
572
|
const parts = [];
|
|
544
573
|
if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(",")}`);
|
|
574
|
+
if (rule.source.match.agents.length > 0) parts.push(`${tokens.agents}:${rule.source.match.agents.join(",")}`);
|
|
545
575
|
const params = Object.entries(rule.source.match.params).map(([key, patterns]) => `${key}=${patterns.join("|")}`).join(" ");
|
|
546
576
|
if (params.length > 0) parts.push(`${tokens.params}:${params}`);
|
|
547
577
|
if (rule.source.match.paths.length > 0) parts.push(`${tokens.paths}:${rule.source.match.paths.join(",")}`);
|
|
@@ -564,6 +594,18 @@ function matchTools(rule, toolName) {
|
|
|
564
594
|
if (rule.tools.length === 0) return true;
|
|
565
595
|
return rule.tools.some((regex) => regex.test(toolName));
|
|
566
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* Whether a rule's agents dimension selects the caller. Any selector glob
|
|
599
|
+
* matching any identity candidate satisfies the dimension; unknown
|
|
600
|
+
* identity (no agent or no candidates) fails the dimension, so an
|
|
601
|
+
* agent-scoped rule can never fire where the caller cannot be identified.
|
|
602
|
+
*/
|
|
603
|
+
function matchAgents(rule, context) {
|
|
604
|
+
if (rule.agents.length === 0) return true;
|
|
605
|
+
const candidates = context.agents;
|
|
606
|
+
if (candidates === void 0 || candidates.length === 0) return false;
|
|
607
|
+
return rule.agents.some((regex) => candidates.some((candidate) => regex.test(candidate)));
|
|
608
|
+
}
|
|
567
609
|
/** Whether a rule's params dimension holds: EVERY key present and matching. */
|
|
568
610
|
function matchParams(rule, args) {
|
|
569
611
|
for (const [key, { positive, negative }] of rule.params) {
|
|
@@ -675,6 +717,7 @@ function parseRuleEntry(raw, index) {
|
|
|
675
717
|
function parseMatch(raw, at) {
|
|
676
718
|
if (raw === void 0) return {
|
|
677
719
|
tools: [],
|
|
720
|
+
agents: [],
|
|
678
721
|
params: {},
|
|
679
722
|
paths: [],
|
|
680
723
|
absent: [],
|
|
@@ -684,10 +727,11 @@ function parseMatch(raw, at) {
|
|
|
684
727
|
}
|
|
685
728
|
};
|
|
686
729
|
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)`);
|
|
730
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "agents" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when");
|
|
731
|
+
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
732
|
return {
|
|
690
733
|
tools: parseStringList(record["tools"], `${at}.match.tools`),
|
|
734
|
+
agents: parseStringList(record["agents"], `${at}.match.agents`),
|
|
691
735
|
params: parseParams(record["params"], `${at}.match.params`),
|
|
692
736
|
paths: parseStringList(record["paths"], `${at}.match.paths`),
|
|
693
737
|
absent: parseStringList(record["absent"], `${at}.match.absent`),
|
|
@@ -851,7 +895,7 @@ function relFromRoot(root, path, rest, rootRest, caseInsensitive) {
|
|
|
851
895
|
const restLower = caseInsensitive ? rest.toLowerCase() : rest;
|
|
852
896
|
const rootRestLower = caseInsensitive ? rootRest.toLowerCase() : rootRest;
|
|
853
897
|
if (rootRestLower.length === 0) return rest.slice(1);
|
|
854
|
-
if (path.toLowerCase() === root.toLowerCase()) return "";
|
|
898
|
+
if (path === root || caseInsensitive && path.toLowerCase() === root.toLowerCase()) return "";
|
|
855
899
|
return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : "";
|
|
856
900
|
}
|
|
857
901
|
/** Convert a path to posix separators. */
|
|
@@ -880,18 +924,21 @@ const EN = {
|
|
|
880
924
|
reloaded: (count, source) => `Reloaded ${count} rule(s) from ${source}.`,
|
|
881
925
|
reloadFailed: (error) => `Reload failed: ${error}. The previous rules are still active.`,
|
|
882
926
|
lastReloadWarning: (error) => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
|
|
927
|
+
dryRunNotice: "Dry-run mode (enforce: false): deny/ask hits are audit-logged only — every call is passed through.",
|
|
883
928
|
unknownArg: (arg) => `Unknown /rules argument "${arg}". ${EN.usage}`,
|
|
884
929
|
usage: "Usage: /rules [reload | decisions [n] | test <tool> <json-args>]",
|
|
885
930
|
decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
|
|
886
931
|
noDecisions: "No permission decisions recorded in this session yet.",
|
|
887
932
|
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)}`}`;
|
|
933
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
934
|
+
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
935
|
},
|
|
891
936
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" matches rule ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
892
937
|
testNoMatch: (tool) => `"${tool}" matches no rule — the call passes through.`,
|
|
893
938
|
testBadJson: (text) => `Invalid JSON arguments "${text}": the arguments must parse as JSON.`,
|
|
894
|
-
|
|
939
|
+
testUnknownFlag: (flag) => `Unknown /rules test flag "${flag}". ${EN.testUsage}`,
|
|
940
|
+
testBadFlag: (flag) => `Invalid /rules test flag "${flag}": expected a value. ${EN.testUsage}`,
|
|
941
|
+
testUsage: "Usage: /rules test [--cwd <dir>] [--env KEY=VALUE]... [--agent <selector>]... <tool> <json-args>, e.g. /rules test bash {\"command\":\"git push origin main\"}",
|
|
895
942
|
unreachableWarning: (numbers) => `Warning: rule${numbers.length > 1 ? "s" : ""} ${numbers.join(", ")} ${numbers.length > 1 ? "are" : "is"} unreachable (shadowed by an earlier catch-all rule).`,
|
|
896
943
|
emptySource: "(no rule file — empty rule set)"
|
|
897
944
|
};
|
|
@@ -902,18 +949,21 @@ const ZH = {
|
|
|
902
949
|
reloaded: (count, source) => `已重载 ${count} 条规则,来自 ${source}。`,
|
|
903
950
|
reloadFailed: (error) => `重载失败:${error}。之前的规则仍然生效。`,
|
|
904
951
|
lastReloadWarning: (error) => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
|
|
952
|
+
dryRunNotice: "干跑模式(enforce: false):deny/ask 命中仅写入审计日志——所有调用照常透传。",
|
|
905
953
|
unknownArg: (arg) => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
|
|
906
954
|
usage: "用法:/rules [reload | decisions [n] | test <工具名> <json-参数>]",
|
|
907
955
|
decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
|
|
908
956
|
noDecisions: "本会话还没有记录任何权限裁决。",
|
|
909
957
|
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)}`}`;
|
|
958
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
959
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : `(规则 ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? "(干跑)" : `(干跑 → ${outcome})` : ""}${reason === void 0 ? "" : `:${short(reason)}`}`;
|
|
912
960
|
},
|
|
913
961
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" 命中规则 ${ruleIndex + 1}(${action}):${short(reason)}`,
|
|
914
962
|
testNoMatch: (tool) => `"${tool}" 未命中任何规则——该调用将透传。`,
|
|
915
963
|
testBadJson: (text) => `无效的 JSON 参数 "${text}":参数必须能被解析为 JSON。`,
|
|
916
|
-
|
|
964
|
+
testUnknownFlag: (flag) => `未知的 /rules test 标志 "${flag}"。${ZH.testUsage}`,
|
|
965
|
+
testBadFlag: (flag) => `无效的 /rules test 标志 "${flag}":缺少取值。${ZH.testUsage}`,
|
|
966
|
+
testUsage: "用法:/rules test [--cwd <目录>] [--env 键=值]... [--agent <选择器>]... <工具名> <json-参数>,例如 /rules test bash {\"command\":\"git push origin main\"}",
|
|
917
967
|
unreachableWarning: (numbers) => `警告:规则 ${numbers.join("、")} 不可达(被前面的通配规则遮蔽)。`,
|
|
918
968
|
emptySource: "(无规则文件——空规则集)"
|
|
919
969
|
};
|
|
@@ -924,18 +974,21 @@ const ES = {
|
|
|
924
974
|
reloaded: (count, source) => `Recargadas ${count} regla(s) desde ${source}.`,
|
|
925
975
|
reloadFailed: (error) => `Error de recarga: ${error}. Las reglas anteriores siguen activas.`,
|
|
926
976
|
lastReloadWarning: (error) => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
|
|
977
|
+
dryRunNotice: "Modo simulación (enforce: false): los aciertos deny/ask solo se registran en auditoría — todas las llamadas pasan.",
|
|
927
978
|
unknownArg: (arg) => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
|
|
928
979
|
usage: "Uso: /rules [reload | decisions [n] | test <herramienta> <json-args>]",
|
|
929
980
|
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
|
|
930
981
|
noDecisions: "Esta sesión aún no registra decisiones de permiso.",
|
|
931
982
|
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)}`}`;
|
|
983
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
984
|
+
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
985
|
},
|
|
935
986
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" coincide con la regla ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
936
987
|
testNoMatch: (tool) => `"${tool}" no coincide con ninguna regla — la llamada pasa sin restricciones.`,
|
|
937
988
|
testBadJson: (text) => `Argumentos JSON inválidos "${text}": los argumentos deben poderse analizar como JSON.`,
|
|
938
|
-
|
|
989
|
+
testUnknownFlag: (flag) => `Bandera de /rules test desconocida "${flag}". ${ES.testUsage}`,
|
|
990
|
+
testBadFlag: (flag) => `Bandera de /rules test inválida "${flag}": falta un valor. ${ES.testUsage}`,
|
|
991
|
+
testUsage: "Uso: /rules test [--cwd <dir>] [--env CLAVE=VALOR]... [--agent <selector>]... <herramienta> <json-args>, p. ej. /rules test bash {\"command\":\"git push origin main\"}",
|
|
939
992
|
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
993
|
emptySource: "(sin archivo de reglas — conjunto vacío)"
|
|
941
994
|
};
|
|
@@ -946,18 +999,21 @@ const PT = {
|
|
|
946
999
|
reloaded: (count, source) => `Recarregadas ${count} regra(s) de ${source}.`,
|
|
947
1000
|
reloadFailed: (error) => `Falha ao recarregar: ${error}. As regras anteriores continuam ativas.`,
|
|
948
1001
|
lastReloadWarning: (error) => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
|
|
1002
|
+
dryRunNotice: "Modo simulação (enforce: false): acertos deny/ask são apenas registrados na auditoria — todas as chamadas passam.",
|
|
949
1003
|
unknownArg: (arg) => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
|
|
950
1004
|
usage: "Uso: /rules [reload | decisions [n] | test <ferramenta> <json-args>]",
|
|
951
1005
|
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
|
|
952
1006
|
noDecisions: "Esta sessão ainda não registrou decisões de permissão.",
|
|
953
1007
|
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)}`}`;
|
|
1008
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
1009
|
+
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
1010
|
},
|
|
957
1011
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" corresponde à regra ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
958
1012
|
testNoMatch: (tool) => `"${tool}" não corresponde a nenhuma regra — a chamada passa livremente.`,
|
|
959
1013
|
testBadJson: (text) => `Argumentos JSON inválidos "${text}": os argumentos precisam ser JSON analisável.`,
|
|
960
|
-
|
|
1014
|
+
testUnknownFlag: (flag) => `Sinalizador de /rules test desconhecido "${flag}". ${PT.testUsage}`,
|
|
1015
|
+
testBadFlag: (flag) => `Sinalizador de /rules test inválido "${flag}": falta um valor. ${PT.testUsage}`,
|
|
1016
|
+
testUsage: "Uso: /rules test [--cwd <dir>] [--env CHAVE=VALOR]... [--agent <seletor>]... <ferramenta> <json-args>, ex.: /rules test bash {\"command\":\"git push origin main\"}",
|
|
961
1017
|
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
1018
|
emptySource: "(sem arquivo de regras — conjunto vazio)"
|
|
963
1019
|
};
|
|
@@ -968,18 +1024,21 @@ const HI = {
|
|
|
968
1024
|
reloaded: (count, source) => `${count} नियम पुनः लोड किए गए, स्रोत ${source}।`,
|
|
969
1025
|
reloadFailed: (error) => `पुनः लोड विफल: ${error}। पिछले नियम अब भी सक्रिय हैं।`,
|
|
970
1026
|
lastReloadWarning: (error) => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
|
|
1027
|
+
dryRunNotice: "ड्राई-रन मोड (enforce: false): deny/ask हिट केवल ऑडिट में दर्ज होते हैं — हर कॉल पास हो जाती है।",
|
|
971
1028
|
unknownArg: (arg) => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
|
|
972
1029
|
usage: "उपयोग: /rules [reload | decisions [n] | test <टूल> <json-args>]",
|
|
973
1030
|
decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
|
|
974
1031
|
noDecisions: "इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।",
|
|
975
1032
|
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)}`}`;
|
|
1033
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason, dryRun, outcome) => {
|
|
1034
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (नियम ${ruleIndex + 1})`}${dryRun ? outcome === void 0 ? " (ड्राई-रन)" : ` (ड्राई-रन → ${outcome})` : ""}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
978
1035
|
},
|
|
979
1036
|
testHit: (tool, ruleIndex, action, reason) => `"${tool}" नियम ${ruleIndex + 1} से मेल खाता है (${action}): ${short(reason)}`,
|
|
980
1037
|
testNoMatch: (tool) => `"${tool}" किसी नियम से मेल नहीं खाता — कॉल बिना रोक पास होगी।`,
|
|
981
1038
|
testBadJson: (text) => `अमान्य JSON तर्क "${text}": तर्क JSON के रूप में पार्स होने चाहिए।`,
|
|
982
|
-
|
|
1039
|
+
testUnknownFlag: (flag) => `अज्ञात /rules test फ़्लैग "${flag}"। ${HI.testUsage}`,
|
|
1040
|
+
testBadFlag: (flag) => `अमान्य /rules test फ़्लैग "${flag}": मान गायब है। ${HI.testUsage}`,
|
|
1041
|
+
testUsage: "उपयोग: /rules test [--cwd <dir>] [--env KEY=मान]... [--agent <चयनकर्ता>]... <टूल> <json-args>, जैसे /rules test bash {\"command\":\"git push origin main\"}",
|
|
983
1042
|
unreachableWarning: (numbers) => `चेतावनी: नियम ${numbers.join(", ")} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
|
|
984
1043
|
emptySource: "(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)"
|
|
985
1044
|
};
|
|
@@ -996,6 +1055,7 @@ const DESCRIBE_TOKENS = {
|
|
|
996
1055
|
en: {
|
|
997
1056
|
allTools: "all tools",
|
|
998
1057
|
tools: "tools",
|
|
1058
|
+
agents: "agents",
|
|
999
1059
|
params: "params",
|
|
1000
1060
|
paths: "paths",
|
|
1001
1061
|
absent: "absent",
|
|
@@ -1007,6 +1067,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1007
1067
|
zh: {
|
|
1008
1068
|
allTools: "全部工具",
|
|
1009
1069
|
tools: "工具",
|
|
1070
|
+
agents: "代理",
|
|
1010
1071
|
params: "参数",
|
|
1011
1072
|
paths: "路径",
|
|
1012
1073
|
absent: "缺省键",
|
|
@@ -1018,6 +1079,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1018
1079
|
es: {
|
|
1019
1080
|
allTools: "todas las herramientas",
|
|
1020
1081
|
tools: "herramientas",
|
|
1082
|
+
agents: "agentes",
|
|
1021
1083
|
params: "parámetros",
|
|
1022
1084
|
paths: "rutas",
|
|
1023
1085
|
absent: "ausentes",
|
|
@@ -1029,6 +1091,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1029
1091
|
pt: {
|
|
1030
1092
|
allTools: "todas as ferramentas",
|
|
1031
1093
|
tools: "ferramentas",
|
|
1094
|
+
agents: "agentes",
|
|
1032
1095
|
params: "parâmetros",
|
|
1033
1096
|
paths: "caminhos",
|
|
1034
1097
|
absent: "ausentes",
|
|
@@ -1040,6 +1103,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1040
1103
|
hi: {
|
|
1041
1104
|
allTools: "सभी टूल",
|
|
1042
1105
|
tools: "टूल",
|
|
1106
|
+
agents: "एजेंट",
|
|
1043
1107
|
params: "पैरामीटर",
|
|
1044
1108
|
paths: "पथ",
|
|
1045
1109
|
absent: "अनुपस्थित",
|
|
@@ -1057,7 +1121,7 @@ const DESCRIBE_TOKENS = {
|
|
|
1057
1121
|
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
1058
1122
|
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
1059
1123
|
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
1060
|
-
* (`list | reload | decisions [n] | test <tool> <json>`), and
|
|
1124
|
+
* (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
|
|
1061
1125
|
* Chokidar-driven reloads. Every registration is an effect.
|
|
1062
1126
|
* @module dsh-permission-rules/runtime
|
|
1063
1127
|
*/
|
|
@@ -1241,19 +1305,29 @@ var PermissionRulesRuntime = class {
|
|
|
1241
1305
|
});
|
|
1242
1306
|
}
|
|
1243
1307
|
}
|
|
1244
|
-
/**
|
|
1245
|
-
|
|
1308
|
+
/**
|
|
1309
|
+
* The match context `when`/`agents` conditions evaluate against: host
|
|
1310
|
+
* facts plus the caller's agent-identity candidates derived from the
|
|
1311
|
+
* session header (`main` for top-level sessions, `subagent` for
|
|
1312
|
+
* subagent children, `preset:<name>` when a preset composed the agent).
|
|
1313
|
+
* @param exec - the pending call whose caller supplies the identity.
|
|
1314
|
+
*/
|
|
1315
|
+
matchContext(exec) {
|
|
1246
1316
|
return {
|
|
1247
1317
|
platform: process.platform,
|
|
1248
|
-
env: process.env
|
|
1318
|
+
env: process.env,
|
|
1319
|
+
agents: agentCandidates(exec?.agent)
|
|
1249
1320
|
};
|
|
1250
1321
|
}
|
|
1251
1322
|
/**
|
|
1252
1323
|
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
1253
1324
|
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
1254
1325
|
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
1255
|
-
* their say.
|
|
1256
|
-
*
|
|
1326
|
+
* their say. Under `enforce: false` (dry-run) a deny/ask hit also
|
|
1327
|
+
* delegates — the record keeps the would-be action with `dryRun: true`
|
|
1328
|
+
* and the actual downstream outcome. Audit is appended once the final
|
|
1329
|
+
* outcome is known, so the recorded `outcome` matches what the waterfall
|
|
1330
|
+
* settled on.
|
|
1257
1331
|
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
1258
1332
|
* @param next - the downstream chain.
|
|
1259
1333
|
* @returns the pre-execute decision.
|
|
@@ -1261,12 +1335,17 @@ var PermissionRulesRuntime = class {
|
|
|
1261
1335
|
async preExecute(exec, next) {
|
|
1262
1336
|
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
1263
1337
|
const loaded = this.rulesFor(cwd);
|
|
1264
|
-
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext());
|
|
1338
|
+
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec));
|
|
1265
1339
|
if (hit === void 0 || hit.rule.action === "allow") {
|
|
1266
1340
|
const decision = await next();
|
|
1267
1341
|
this.audit(exec, loaded, hit, decision.kind);
|
|
1268
1342
|
return decision;
|
|
1269
1343
|
}
|
|
1344
|
+
if (!this.config.enforce) {
|
|
1345
|
+
const decision = await next();
|
|
1346
|
+
this.audit(exec, loaded, hit, decision.kind, true);
|
|
1347
|
+
return decision;
|
|
1348
|
+
}
|
|
1270
1349
|
const outcome = hit.rule.action;
|
|
1271
1350
|
this.audit(exec, loaded, hit, outcome);
|
|
1272
1351
|
if (outcome === "deny") return {
|
|
@@ -1284,15 +1363,18 @@ var PermissionRulesRuntime = class {
|
|
|
1284
1363
|
* envelope's `ignorable: true` marker so any harness build can load the
|
|
1285
1364
|
* log (readers that do not know the type skip the audit record instead of
|
|
1286
1365
|
* refusing the session). `source` names the matched rule's file, or the
|
|
1287
|
-
* nearest effective file on a passthrough
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1366
|
+
* nearest effective file on a passthrough; `cwd` names the workspace the
|
|
1367
|
+
* rules were resolved for; `dryRun` marks would-be deny/ask hits under
|
|
1368
|
+
* `enforce: false`. Agentless calls have no session to audit; append
|
|
1369
|
+
* failures are contained so an audit hiccup can never change a
|
|
1370
|
+
* permission decision.
|
|
1290
1371
|
* @param exec - the pending call.
|
|
1291
1372
|
* @param loaded - the rules in effect.
|
|
1292
1373
|
* @param hit - the first matching rule, or undefined for passthrough.
|
|
1293
1374
|
* @param outcome - the final pre-execute decision.
|
|
1375
|
+
* @param dryRun - mark the record as a would-be decision (dry-run mode).
|
|
1294
1376
|
*/
|
|
1295
|
-
audit(exec, loaded, hit, outcome) {
|
|
1377
|
+
audit(exec, loaded, hit, outcome, dryRun = false) {
|
|
1296
1378
|
if (this.config.audit === "hits" && hit === void 0) return;
|
|
1297
1379
|
const agent = exec.agent;
|
|
1298
1380
|
if (agent === void 0) return;
|
|
@@ -1303,10 +1385,12 @@ var PermissionRulesRuntime = class {
|
|
|
1303
1385
|
source: hit === void 0 ? loaded.sources[0] ?? "" : loaded.sources[hit.rule.sourceIndex] ?? "",
|
|
1304
1386
|
action: hit === void 0 ? "passthrough" : hit.rule.action,
|
|
1305
1387
|
outcome,
|
|
1388
|
+
cwd: loaded.cwd,
|
|
1306
1389
|
...hit !== void 0 ? {
|
|
1307
1390
|
ruleIndex: hit.ruleIndex,
|
|
1308
1391
|
reason: hit.rule.reason
|
|
1309
|
-
} : {}
|
|
1392
|
+
} : {},
|
|
1393
|
+
...dryRun ? { dryRun: true } : {}
|
|
1310
1394
|
}, { ignorable: true });
|
|
1311
1395
|
} catch (error) {
|
|
1312
1396
|
this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`);
|
|
@@ -1315,8 +1399,9 @@ var PermissionRulesRuntime = class {
|
|
|
1315
1399
|
/**
|
|
1316
1400
|
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
1317
1401
|
* 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
|
|
1402
|
+
* [n]` shows the session's audit trail; `/rules test [flags] <tool>
|
|
1403
|
+
* <json>` dry-evaluates the rules against a hypothetical call (flags
|
|
1404
|
+
* override the workspace, host env, and agent identity). Command output
|
|
1320
1405
|
* stays in the UI — nothing here is injected into the model context.
|
|
1321
1406
|
* @param invocation - the received command invocation.
|
|
1322
1407
|
* @returns the command result shown to the user.
|
|
@@ -1361,40 +1446,7 @@ var PermissionRulesRuntime = class {
|
|
|
1361
1446
|
}
|
|
1362
1447
|
return this.decisionsCommand(invocation, count, prose);
|
|
1363
1448
|
}
|
|
1364
|
-
if (verb === "test")
|
|
1365
|
-
const verbText = verbRaw ?? "";
|
|
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
|
-
}
|
|
1449
|
+
if (verb === "test") return this.testCommand(raw, verbRaw ?? "", invocation, cwd, prose);
|
|
1398
1450
|
if (verb !== "") return {
|
|
1399
1451
|
kind: "error",
|
|
1400
1452
|
text: prose.unknownArg(invocation.rawInput.trim())
|
|
@@ -1417,12 +1469,95 @@ var PermissionRulesRuntime = class {
|
|
|
1417
1469
|
if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map((index) => index + 1)));
|
|
1418
1470
|
}
|
|
1419
1471
|
if (loaded.lastError !== void 0) lines.push(prose.lastReloadWarning(loaded.lastError));
|
|
1472
|
+
if (!this.config.enforce) lines.push(prose.dryRunNotice);
|
|
1420
1473
|
lines.push(prose.usage);
|
|
1421
1474
|
return {
|
|
1422
1475
|
kind: "success",
|
|
1423
1476
|
text: lines.join("\n")
|
|
1424
1477
|
};
|
|
1425
1478
|
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Execute `/rules test` with optional leading flags: `--cwd <dir>`
|
|
1481
|
+
* evaluates against that workspace (rule discovery AND path
|
|
1482
|
+
* normalization), `--env KEY=VALUE` (repeatable) overrides host env for
|
|
1483
|
+
* `when.env` matching, and `--agent <selector>` (repeatable) supplies
|
|
1484
|
+
* agent-identity candidates for the `agents` dimension. The JSON
|
|
1485
|
+
* argument tail is kept verbatim, so quoted JSON survives unchanged.
|
|
1486
|
+
* @param raw - the full raw command input.
|
|
1487
|
+
* @param verbRaw - the verb as typed.
|
|
1488
|
+
* @param invocation - the received command invocation (session agent).
|
|
1489
|
+
* @param sessionCwd - the session's workspace root.
|
|
1490
|
+
* @param prose - localized output vocabulary.
|
|
1491
|
+
* @returns the dry-evaluation result.
|
|
1492
|
+
*/
|
|
1493
|
+
testCommand(raw, verbRaw, invocation, sessionCwd, prose) {
|
|
1494
|
+
let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : "";
|
|
1495
|
+
const envOverrides = {};
|
|
1496
|
+
const agentSelectors = [];
|
|
1497
|
+
let testCwd;
|
|
1498
|
+
let parsed = nextToken(rest);
|
|
1499
|
+
while (parsed !== void 0 && parsed.token.startsWith("--")) {
|
|
1500
|
+
const flag = parsed.token;
|
|
1501
|
+
const value = nextToken(parsed.rest);
|
|
1502
|
+
if (value === void 0 || value.token.startsWith("--")) return {
|
|
1503
|
+
kind: "error",
|
|
1504
|
+
text: prose.testBadFlag(flag)
|
|
1505
|
+
};
|
|
1506
|
+
if (flag === "--cwd") testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token);
|
|
1507
|
+
else if (flag === "--env") {
|
|
1508
|
+
const equals = value.token.indexOf("=");
|
|
1509
|
+
if (equals <= 0) return {
|
|
1510
|
+
kind: "error",
|
|
1511
|
+
text: prose.testBadFlag(`${flag} ${value.token}`)
|
|
1512
|
+
};
|
|
1513
|
+
envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1);
|
|
1514
|
+
} else if (flag === "--agent") agentSelectors.push(value.token);
|
|
1515
|
+
else return {
|
|
1516
|
+
kind: "error",
|
|
1517
|
+
text: prose.testUnknownFlag(flag)
|
|
1518
|
+
};
|
|
1519
|
+
rest = value.rest;
|
|
1520
|
+
parsed = nextToken(rest);
|
|
1521
|
+
}
|
|
1522
|
+
const tool = parsed?.token;
|
|
1523
|
+
const jsonText = parsed === void 0 ? "" : parsed.rest.trim();
|
|
1524
|
+
if (tool === void 0 || tool.length === 0) return {
|
|
1525
|
+
kind: "error",
|
|
1526
|
+
text: prose.testUsage
|
|
1527
|
+
};
|
|
1528
|
+
let args;
|
|
1529
|
+
try {
|
|
1530
|
+
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
1531
|
+
} catch {
|
|
1532
|
+
return {
|
|
1533
|
+
kind: "error",
|
|
1534
|
+
text: prose.testBadJson(jsonText)
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
const evalCwd = testCwd ?? sessionCwd;
|
|
1538
|
+
let loaded;
|
|
1539
|
+
try {
|
|
1540
|
+
loaded = this.rulesFor(evalCwd);
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
return {
|
|
1543
|
+
kind: "error",
|
|
1544
|
+
text: error instanceof Error ? error.message : String(error)
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
const context = {
|
|
1548
|
+
platform: process.platform,
|
|
1549
|
+
env: Object.keys(envOverrides).length === 0 ? process.env : {
|
|
1550
|
+
...process.env,
|
|
1551
|
+
...envOverrides
|
|
1552
|
+
},
|
|
1553
|
+
agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent)
|
|
1554
|
+
};
|
|
1555
|
+
const hit = matchRules(loaded.compiled, tool, args, evalCwd, context);
|
|
1556
|
+
return {
|
|
1557
|
+
kind: "success",
|
|
1558
|
+
text: hit === void 0 ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason)
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1426
1561
|
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
1427
1562
|
decisionsCommand(invocation, count, prose) {
|
|
1428
1563
|
const decisions = invocation.agent.session.events.filter((event) => event.type === "permissionRules/decision");
|
|
@@ -1434,7 +1569,7 @@ var PermissionRulesRuntime = class {
|
|
|
1434
1569
|
const lines = [prose.decisionsHeader(shown.length, decisions.length)];
|
|
1435
1570
|
for (const event of shown) {
|
|
1436
1571
|
const data = event.data;
|
|
1437
|
-
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason));
|
|
1572
|
+
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome));
|
|
1438
1573
|
}
|
|
1439
1574
|
return {
|
|
1440
1575
|
kind: "success",
|
|
@@ -1579,21 +1714,76 @@ function apply(ctx, config) {
|
|
|
1579
1714
|
ctx.commands.register({
|
|
1580
1715
|
name: "rules",
|
|
1581
1716
|
description: "list, reload, audit, or dry-test the active permission rules for this workspace",
|
|
1582
|
-
input: { hint: "[reload | decisions [n] | test <tool> <json-args>]" },
|
|
1717
|
+
input: { hint: "[reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] <tool> <json-args>]" },
|
|
1583
1718
|
handler: (invocation) => runtime.command(invocation)
|
|
1584
1719
|
});
|
|
1585
1720
|
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Identity candidates for the `agents` match dimension, derived from the
|
|
1723
|
+
* caller agent's session header: `main` for top-level sessions, `subagent`
|
|
1724
|
+
* for subagent children (`header.origin === 'subagent'`), and
|
|
1725
|
+
* `preset:<name>` when a preset composed the agent. No agent (or an
|
|
1726
|
+
* unidentifiable one) yields no candidates, so agent-scoped rules fail
|
|
1727
|
+
* closed instead of matching an unknown caller.
|
|
1728
|
+
* @param agent - the calling agent, when the call has one.
|
|
1729
|
+
* @returns the candidate strings, in a stable order.
|
|
1730
|
+
*/
|
|
1731
|
+
function agentCandidates(agent) {
|
|
1732
|
+
if (agent === void 0) return [];
|
|
1733
|
+
const header = agent.session.header;
|
|
1734
|
+
const candidates = [header.origin === "subagent" ? "subagent" : "main"];
|
|
1735
|
+
if (typeof header.agentPreset === "string" && header.agentPreset.length > 0) candidates.push(`preset:${header.agentPreset}`);
|
|
1736
|
+
return candidates;
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Pull the next whitespace-delimited argument from a command tail,
|
|
1740
|
+
* honoring single/double quotes (with backslash escapes) so JSON blobs and
|
|
1741
|
+
* paths with spaces survive. The remainder is returned verbatim — the JSON
|
|
1742
|
+
* argument tail of `/rules test` is never re-tokenized.
|
|
1743
|
+
* @param text - the remaining command tail.
|
|
1744
|
+
* @returns the token plus the untouched remainder, or undefined when only
|
|
1745
|
+
* whitespace remains.
|
|
1746
|
+
*/
|
|
1747
|
+
function nextToken(text) {
|
|
1748
|
+
let i = 0;
|
|
1749
|
+
while (i < text.length && /\s/.test(text[i])) i += 1;
|
|
1750
|
+
if (i >= text.length) return void 0;
|
|
1751
|
+
const quote = text[i];
|
|
1752
|
+
if (quote === "\"" || quote === "'") {
|
|
1753
|
+
let end = i + 1;
|
|
1754
|
+
while (end < text.length && text[end] !== quote) {
|
|
1755
|
+
if (text[end] === "\\") end += 1;
|
|
1756
|
+
end += 1;
|
|
1757
|
+
}
|
|
1758
|
+
if (end >= text.length) return {
|
|
1759
|
+
token: text.slice(i + 1),
|
|
1760
|
+
rest: ""
|
|
1761
|
+
};
|
|
1762
|
+
return {
|
|
1763
|
+
token: text.slice(i + 1, end),
|
|
1764
|
+
rest: text.slice(end + 1)
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
let end = i;
|
|
1768
|
+
while (end < text.length && !/\s/.test(text[end])) end += 1;
|
|
1769
|
+
return {
|
|
1770
|
+
token: text.slice(i, end),
|
|
1771
|
+
rest: text.slice(end)
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1586
1774
|
//#endregion
|
|
1587
1775
|
//#region src/index.ts
|
|
1588
1776
|
/**
|
|
1589
1777
|
* `dsh-permission-rules` — Claude Code-style declarative permission rules
|
|
1590
1778
|
* 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
|
-
*
|
|
1779
|
+
* an ordered `allow`/`deny`/`ask` rule list (tool-name globs, agent
|
|
1780
|
+
* selectors, argument glob/regex matching, workspace-path matching) per
|
|
1781
|
+
* session cwd; deny and ask decisions short-circuit the chain, while allow
|
|
1782
|
+
* and passthrough always delegate via `next()`. Under `enforce: false` the
|
|
1783
|
+
* plugin audits what it WOULD decide and lets everything through (dry-run
|
|
1784
|
+
* policy rollout). `ask` rides the official approval seam, where answerers
|
|
1785
|
+
* such as `dsh-auto-review` or a human UI decide — the plugin itself never
|
|
1786
|
+
* runs a reviewer. Every decision is audit-logged.
|
|
1597
1787
|
*
|
|
1598
1788
|
* Function plugin — no default export (the Loader unwraps
|
|
1599
1789
|
* `exports.default ?? exports`, and a stray default would discard
|