dsh-perm-gate 5.3.4 → 5.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.ja.md +6 -0
- package/CHANGELOG.ko.md +6 -0
- package/CHANGELOG.md +8 -0
- package/README.ja.md +24 -0
- package/README.ko.md +24 -0
- package/README.md +42 -0
- package/README.zh.md +30 -0
- package/dsh.plugin.json +3 -3
- package/lib/client.js +319 -0
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +40 -1
- package/lib/config.js +7 -1
- package/lib/cred-families.d.ts +22 -0
- package/lib/cred-families.js +63 -0
- package/lib/engine.js +36 -3
- package/lib/evaluate.d.ts +8 -0
- package/lib/events.d.ts +19 -0
- package/lib/events.js +99 -4
- package/lib/index.js +39 -4
- package/lib/preset-rules.d.ts +33 -1
- package/lib/preset-rules.js +137 -0
- package/lib/runtime.d.ts +22 -0
- package/lib/runtime.js +23 -1
- package/package.json +1 -1
package/lib/preset-rules.js
CHANGED
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import { parse, stringify } from 'yaml';
|
|
32
32
|
import { parsePermissionsDocument } from './rule.js';
|
|
33
|
+
import { CRED_FAMILY_IDS } from './cred-families.js';
|
|
34
|
+
/**
|
|
35
|
+
* Shipped version of the preset table. Activation backfills (idempotent, revocation-
|
|
36
|
+
* preserving) whenever the stored `presetRulesVersion` lags this number — bump it
|
|
37
|
+
* whenever `PRESET_RULES_YAML` gains entries that existing installs must receive
|
|
38
|
+
* (v1 added the seven `cred.*` family rows).
|
|
39
|
+
*/
|
|
40
|
+
export const PRESET_TABLE_VERSION = 1;
|
|
33
41
|
/** Tag prefix carried by every preset entry's reason; locates an entry for revocation. */
|
|
34
42
|
export const PRESET_RULE_MARKER = '[deny-rule:';
|
|
35
43
|
/** Extract the `[deny-rule:<id>]` tag from one reason string (undefined when untagged). */
|
|
@@ -140,6 +148,9 @@ deny:
|
|
|
140
148
|
- command: [find]
|
|
141
149
|
argv: { pipeline: ["find *-exec rm*", "find *-delete*"] }
|
|
142
150
|
reason: "[deny-rule:find.exec-rm-recursive-force] find -exec rm removes the whole tree per match. Dry-run plain find, delete explicit paths."
|
|
151
|
+
- command: [rsync]
|
|
152
|
+
argv: { pipeline: ["rsync * --delete*", "rsync --delete*"] }
|
|
153
|
+
reason: "[deny-rule:rsync.delete] rsync --delete erases destination files absent from the source. Dry-run with rsync -n --delete first."
|
|
143
154
|
- command: [dd]
|
|
144
155
|
argv: { pipeline: ["dd * of=/dev/sd*", "dd * of=/dev/nvme*", "dd * of=/dev/mmcblk*"] }
|
|
145
156
|
reason: "[deny-rule:dd.device-write] dd writing to a block device destroys the disk."
|
|
@@ -232,6 +243,36 @@ ask:
|
|
|
232
243
|
args: ["*/.ssh", "*/.ssh/*", "*/.aws", "*/.aws/*", "*/.gnupg", "*/.gnupg/*", "*/.config/gcloud/*", "*/.claude/*", "*/.codex/*"]
|
|
233
244
|
reason: "[deny-rule:secret.read-home-credential-dirs] Browsing ssh/cloud/coding-CLI credential stores requires confirmation."
|
|
234
245
|
|
|
246
|
+
# ── Credential family ladder (read side) ──────────────────────────────────
|
|
247
|
+
# One row per family, restricted to the file-READ tools: decide() pre-queries the
|
|
248
|
+
# rule layer for READ calls, so these rows are what a read hits (shell reads stay
|
|
249
|
+
# under the secret.* rows above, which match first in this section). The settings
|
|
250
|
+
# face rewrites each row as the family's rung: deny - moved to the deny section;
|
|
251
|
+
# ask - stays here; readonly/readwrite - enabled:false (readwrite additionally
|
|
252
|
+
# lists the family in credWriteExemptFamilies for the engine's write clause).
|
|
253
|
+
# Custom credentialPaths have no family and are never write-exempt.
|
|
254
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
255
|
+
args: ["*/.ssh", "*/.ssh/*", "*id_rsa", "*id_ed25519", "*id_ecdsa", "*id_dsa", "*authorized_keys"]
|
|
256
|
+
reason: "[deny-rule:cred.ssh] Reading SSH keys or authorized_keys needs confirmation (family rung: ask)."
|
|
257
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
258
|
+
args: ["*/.aws", "*/.aws/*", "*/.config/gcloud", "*/.config/gcloud/*", "*/.azure", "*/.azure/*", "*credentials.yaml", "*credentials.json"]
|
|
259
|
+
reason: "[deny-rule:cred.cloud] Reading cloud-provider credential stores needs confirmation (family rung: ask)."
|
|
260
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
261
|
+
args: ["*/.npmrc", "*.npmrc", "*/.pypirc", "*/.netrc", "*_netrc", "*/pip.conf"]
|
|
262
|
+
reason: "[deny-rule:cred.package] Reading package-registry auth files needs confirmation (family rung: ask)."
|
|
263
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
264
|
+
args: ["*/.git-credentials", "*.git-credentials"]
|
|
265
|
+
reason: "[deny-rule:cred.gitcreds] Reading the git credential store needs confirmation (family rung: ask)."
|
|
266
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
267
|
+
args: ["*/.env", "*/.env.*", "*.env"]
|
|
268
|
+
reason: "[deny-rule:cred.env] Reading project env files needs confirmation (family rung: ask; readwrite also lets edits, npm-login-style)."
|
|
269
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
270
|
+
args: ["*/.claude", "*/.claude/*", "*/.codex", "*/.codex/*", "*/.config/gh", "*/.config/gh/*"]
|
|
271
|
+
reason: "[deny-rule:cred.cli] Reading coding-CLI credential stores needs confirmation (family rung: ask)."
|
|
272
|
+
- tools: [read, read_image, grep, glob, ls, lsp]
|
|
273
|
+
args: ["*/.pgpass", "*pgpass", "*/.my.cnf"]
|
|
274
|
+
reason: "[deny-rule:cred.db] Reading database credential files needs confirmation (family rung: ask)."
|
|
275
|
+
|
|
235
276
|
# Self-protection: editing the gate's own rule sources needs confirmation —
|
|
236
277
|
# revocation must be a human act, not something an agent can slide through.
|
|
237
278
|
# (Settings-namespace edits go through the host UI, not a tool call, and are
|
|
@@ -386,3 +427,99 @@ export function ensurePresetRulesFile(rulesFile, markerFile) {
|
|
|
386
427
|
return { action: 'skipped', added: 0 };
|
|
387
428
|
}
|
|
388
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* The current rung per family, read out of a RAW rules document plus the write-exemption
|
|
432
|
+
* list (GET side of the settings face; the card renders straight from this map).
|
|
433
|
+
* A missing row reports 'ask' — the shipped default every fresh seed writes.
|
|
434
|
+
*/
|
|
435
|
+
export function credRungsOf(rawDoc, exempt = []) {
|
|
436
|
+
const root = rawDoc !== null && typeof rawDoc === 'object' && !Array.isArray(rawDoc)
|
|
437
|
+
? rawDoc
|
|
438
|
+
: {};
|
|
439
|
+
const target = root['permissions'] !== null && typeof root['permissions'] === 'object' && !Array.isArray(root['permissions'])
|
|
440
|
+
? root['permissions']
|
|
441
|
+
: root;
|
|
442
|
+
const out = {};
|
|
443
|
+
for (const family of CRED_FAMILY_IDS) {
|
|
444
|
+
const tag = `cred.${family}`;
|
|
445
|
+
let rung = 'ask';
|
|
446
|
+
for (const key of ['deny', 'ask']) {
|
|
447
|
+
const arr = Array.isArray(target[key]) ? target[key] : [];
|
|
448
|
+
const row = arr.find((e) => presetRuleId(e.reason) === tag);
|
|
449
|
+
if (row !== undefined) {
|
|
450
|
+
if (row.enabled === false)
|
|
451
|
+
rung = exempt.includes(family) ? 'readwrite' : 'readonly';
|
|
452
|
+
else
|
|
453
|
+
rung = key === 'deny' ? 'deny' : 'ask';
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
out[family] = rung;
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Rewrite one `cred.<family>` row of a RAW rules document to the chosen rung, and
|
|
463
|
+
* maintain the paired write-exemption list. Both stores are returned together; the
|
|
464
|
+
* caller persists them in one settings update. Idempotent: applying the same rung
|
|
465
|
+
* twice is a no-op. Revocation-preserving elsewhere untouched — this function only
|
|
466
|
+
* ever moves or enables/disables the family's own row.
|
|
467
|
+
*
|
|
468
|
+
* `readonly` and `readwrite` both disable the read row (reads then fall through to
|
|
469
|
+
* the READ auto-allow); they differ only in `exempt`: readwrite additionally lists
|
|
470
|
+
* the family, which steps the engine's credential-store WRITE clause aside for that
|
|
471
|
+
* family's built-in paths — and nothing else.
|
|
472
|
+
*/
|
|
473
|
+
export function setCredFamilyAction(rawDoc, exempt, family, rung) {
|
|
474
|
+
const root = rawDoc !== null && typeof rawDoc === 'object' && !Array.isArray(rawDoc)
|
|
475
|
+
? { ...rawDoc }
|
|
476
|
+
: {};
|
|
477
|
+
const hasWrapper = root['permissions'] !== null && typeof root['permissions'] === 'object' && !Array.isArray(root['permissions']);
|
|
478
|
+
// Always work on a fresh wrapper copy — the shared `permissions` object is frozen
|
|
479
|
+
// on the live host, and `defaultAction` writes below must not hit it.
|
|
480
|
+
const target = hasWrapper ? { ...root['permissions'] } : root;
|
|
481
|
+
if (hasWrapper)
|
|
482
|
+
root['permissions'] = target;
|
|
483
|
+
const tag = `cred.${family}`;
|
|
484
|
+
// Settings snapshots are deep-frozen on the live host: containers AND elements.
|
|
485
|
+
// Every section array we touch is replaced by a copy first — `splice` on a frozen
|
|
486
|
+
// array throws in strict mode (`Cannot delete property`), the same class of failure
|
|
487
|
+
// as the frozen-row write. Copy-on-write for arrays and rows alike.
|
|
488
|
+
const takeSection = (key) => {
|
|
489
|
+
const cur = Array.isArray(target[key]) ? target[key] : [];
|
|
490
|
+
const copy = [...cur];
|
|
491
|
+
target[key] = copy;
|
|
492
|
+
return copy;
|
|
493
|
+
};
|
|
494
|
+
// Detach this family's row from wherever it sits. NO whole-table merge here: the
|
|
495
|
+
// merge dedupes per section, so a row previously moved deny↔ask would be
|
|
496
|
+
// re-appended to its preset section and the rung would silently revert (measured
|
|
497
|
+
// on the frozen-snapshot regression). Only this family's row is touched.
|
|
498
|
+
let entry;
|
|
499
|
+
for (const key of ['deny', 'ask']) {
|
|
500
|
+
const arr = takeSection(key);
|
|
501
|
+
const i = arr.findIndex((e) => presetRuleId(e.reason) === tag);
|
|
502
|
+
if (i >= 0) {
|
|
503
|
+
// Copy before mutate: settings snapshots are deep-frozen on the live host.
|
|
504
|
+
entry = { ...arr.splice(i, 1)[0] };
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (entry === undefined) {
|
|
509
|
+
// Row absent (fresh doc or pruned): seed just this family's preset row.
|
|
510
|
+
const src = parsePresetDoc().ask.find((e) => presetRuleId(e.reason) === tag);
|
|
511
|
+
if (src !== undefined)
|
|
512
|
+
entry = rawEntry(src);
|
|
513
|
+
}
|
|
514
|
+
if (entry !== undefined) {
|
|
515
|
+
entry.enabled = rung !== 'readonly' && rung !== 'readwrite';
|
|
516
|
+
const destKey = rung === 'deny' ? 'deny' : 'ask';
|
|
517
|
+
const dest = takeSection(destKey);
|
|
518
|
+
dest.push(entry);
|
|
519
|
+
}
|
|
520
|
+
if (typeof root.defaultAction !== 'string' && typeof target.defaultAction !== 'string') {
|
|
521
|
+
target.defaultAction = 'ask';
|
|
522
|
+
}
|
|
523
|
+
const nextExempt = rung === 'readwrite' ? [...new Set([...exempt, family])] : exempt.filter((f) => f !== family);
|
|
524
|
+
return { doc: root, exempt: nextExempt };
|
|
525
|
+
}
|
package/lib/runtime.d.ts
CHANGED
|
@@ -156,6 +156,21 @@ export interface PermGateRuntimeOptions extends PermGateConfig {
|
|
|
156
156
|
*/
|
|
157
157
|
readonly readSafenetPass?: () => boolean;
|
|
158
158
|
readonly readSafenetAsk?: () => boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Live read of the `allowProtectedBranchPush` opt-out (volatile setting, default false
|
|
161
|
+
* keeps the P0 protected-branch floor). When true the floor steps aside AND the three
|
|
162
|
+
* flat push keywords in the deny-keyword blacklist are exempted — the keyword layer runs
|
|
163
|
+
* before every ask, so leaving them armed would make the switch a silent no-op. Nothing
|
|
164
|
+
* else widens: wipes, redirects, credentials and the rest of the floor stay armed, and
|
|
165
|
+
* the seeded `git.push-force` rule still asks a person.
|
|
166
|
+
*/
|
|
167
|
+
readonly readAllowProtectedBranchPush?: () => boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Live read of `credWriteExemptFamilies` — the readwrite rung of the credential-family
|
|
170
|
+
* ladder. Consulted ONLY by the engine's credential-store write clause; already filtered
|
|
171
|
+
* to the family roster by the wiring.
|
|
172
|
+
*/
|
|
173
|
+
readonly readCredWriteExemptFamilies?: () => readonly string[];
|
|
159
174
|
/**
|
|
160
175
|
* Optional live source of the script-review switch: attach the body of every script a
|
|
161
176
|
* call runs to the review, so the grader and the human judge code, not a filename.
|
|
@@ -529,6 +544,13 @@ export declare class PermGateRuntime {
|
|
|
529
544
|
* An empty override is treated as "no meaningful override" (the blacklist is
|
|
530
545
|
* protective and stays on); schemastery materializes unset arrays as `[]`. */
|
|
531
546
|
private liveDenyKeywords;
|
|
547
|
+
/**
|
|
548
|
+
* The push-floor opt-out, read live so the settings toggle lands on the next call.
|
|
549
|
+
* `PUSH_FLOOR_KEYWORDS` are the flat blacklist entries that encode the same intent as
|
|
550
|
+
* the P0 protected-branch floor; the opt-out exempts exactly these, nothing more.
|
|
551
|
+
*/
|
|
552
|
+
private liveAllowProtectedBranchPush;
|
|
553
|
+
private effectiveDenyKeywords;
|
|
532
554
|
/** The user-declared credential paths, as configured (may be empty). */
|
|
533
555
|
private liveCredentialPaths;
|
|
534
556
|
/**
|
package/lib/runtime.js
CHANGED
|
@@ -36,6 +36,12 @@ import { DEFAULT_DENY_KEYWORDS } from './deny-defaults.js';
|
|
|
36
36
|
import { permissionPresetOf, presetInScope } from './preset.js';
|
|
37
37
|
import { resolveTierMode } from './unattended.js';
|
|
38
38
|
import { resolveGatePresets } from './config.js';
|
|
39
|
+
/**
|
|
40
|
+
* The flat blacklist entries that encode the same intent as the P0 protected-branch push
|
|
41
|
+
* floor. `allowProtectedBranchPush` exempts exactly these (and the floor itself) — every
|
|
42
|
+
* other keyword and floor branch stays armed.
|
|
43
|
+
*/
|
|
44
|
+
const PUSH_FLOOR_KEYWORDS = new Set(['push --force', 'force-push', 'force push']);
|
|
39
45
|
/** Locate a rule by its chain-wide index (deny entries first, then allow, then ask). */
|
|
40
46
|
function findRuleByIndex(ruleset, index) {
|
|
41
47
|
return (ruleset.deny.find((entry) => entry.index === index) ??
|
|
@@ -410,6 +416,8 @@ export class PermGateRuntime {
|
|
|
410
416
|
caseInsensitive: this.options.caseInsensitivePaths ?? true,
|
|
411
417
|
dshHome: this.options.dshHome,
|
|
412
418
|
credentialPaths: this.liveCredentialPaths(),
|
|
419
|
+
allowProtectedBranchPush: this.liveAllowProtectedBranchPush(),
|
|
420
|
+
credWriteExempt: this.options.readCredWriteExemptFamilies?.() ?? [],
|
|
413
421
|
scanKeys: this.liveScanKeys(),
|
|
414
422
|
agentCandidates: extractAgentCandidates(exec),
|
|
415
423
|
};
|
|
@@ -817,6 +825,20 @@ export class PermGateRuntime {
|
|
|
817
825
|
const appended = extra.map((k) => String(k).trim()).filter((k) => k !== '');
|
|
818
826
|
return appended.length === 0 ? base : [...new Set([...base, ...appended])];
|
|
819
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* The push-floor opt-out, read live so the settings toggle lands on the next call.
|
|
830
|
+
* `PUSH_FLOOR_KEYWORDS` are the flat blacklist entries that encode the same intent as
|
|
831
|
+
* the P0 protected-branch floor; the opt-out exempts exactly these, nothing more.
|
|
832
|
+
*/
|
|
833
|
+
liveAllowProtectedBranchPush() {
|
|
834
|
+
return this.options.readAllowProtectedBranchPush?.() ?? this.options.allowProtectedBranchPush ?? false;
|
|
835
|
+
}
|
|
836
|
+
effectiveDenyKeywords() {
|
|
837
|
+
const keywords = this.liveDenyKeywords();
|
|
838
|
+
if (!this.liveAllowProtectedBranchPush())
|
|
839
|
+
return keywords;
|
|
840
|
+
return keywords.filter((k) => !PUSH_FLOOR_KEYWORDS.has(String(k).trim()));
|
|
841
|
+
}
|
|
820
842
|
/** The user-declared credential paths, as configured (may be empty). */
|
|
821
843
|
liveCredentialPaths() {
|
|
822
844
|
const read = this.options.readCredentialPaths;
|
|
@@ -855,7 +877,7 @@ export class PermGateRuntime {
|
|
|
855
877
|
* call, the reason, or the event: the reason names the keyword, never the blob.
|
|
856
878
|
*/
|
|
857
879
|
denyKeywordHit(exec) {
|
|
858
|
-
const keywords = this.
|
|
880
|
+
const keywords = this.effectiveDenyKeywords();
|
|
859
881
|
if (keywords.length === 0)
|
|
860
882
|
return undefined;
|
|
861
883
|
const text = keywordScanText(exec.name, exec.arguments ?? {}, exec.commandText, this.liveScanKeys());
|