dsh-perm-gate 5.2.0 → 5.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/lib/config.d.ts CHANGED
@@ -66,6 +66,75 @@ export interface PermGateConfig {
66
66
  * widens decision before the human/LLM seam.
67
67
  */
68
68
  readonly permissive?: boolean;
69
+ /**
70
+ * YOLO — the switch behind the `permissive-yolo` tier, the model-backed human-free
71
+ * implementation. It never raises a human confirmation: what the mechanical screen names
72
+ * (irreversible, unlearnable, unreadable payload) is blocked and fed back to the main LLM
73
+ * as a `工具审核意见` user message, and the residual goes to the review model, which
74
+ * answers in the person's place — risky blocks, anything else runs.
75
+ *
76
+ * This is a per-tier switch, not a session-wide flow: it governs the `permissive-yolo`
77
+ * preset and nothing else, so turning it off changes no other tier's behaviour. The tier
78
+ * is the model-backed one by definition; with no reviewer in seat it still refuses to ask
79
+ * a person (that is the promise), settles on the mechanical screen alone, and records that
80
+ * the model never answered instead of letting the tier quietly become a different one.
81
+ */
82
+ readonly yolo?: boolean;
83
+ /**
84
+ * `safenet-pass` — the switch behind the `permissive-mech` tier: the deterministic
85
+ * mechanical screen plus the preset opinions take over the ask. Named danger is blocked
86
+ * and fed back to the main LLM, everything the nets cannot condemn runs. No human prompt
87
+ * and **no model call at all** — the reviewed form of `approval: never`, and the tier for
88
+ * a run with no reviewer LLM or where the tokens are not worth spending.
89
+ */
90
+ readonly safenetPass?: boolean;
91
+ /**
92
+ * `safenet-ask` — the switch behind the `permissive-snet` tier: the mechanical screen
93
+ * still filters and lists its doubts, but the ask stays with a **person** and no grader is
94
+ * consulted. The shape the gate takes when no reviewer LLM is connected and you would
95
+ * rather answer than auto-run. Uncommon by design.
96
+ */
97
+ readonly safenetAsk?: boolean;
98
+ /**
99
+ * Degraded review — fall back to a **rule-only gate when there is no reviewer**.
100
+ *
101
+ * With `permissiveStrategies.llmAssist` on, an `ask` is normally graded by the
102
+ * configured model, and every path that cannot grade it (no endpoint/model set up,
103
+ * host `llm` absent, transport failure → `unresolved`) is fail-closed to a *human*
104
+ * confirmation. In a headless or unattended run that is a stall: nobody answers.
105
+ * This switch degrades those two no-reviewer paths to the deterministic nets only —
106
+ * a cc-safety-net-class gate: danger the rules can name (irreversible / unlearnable /
107
+ * opaque payload) is **blocked** and the reason is fed back to the main LLM, anything
108
+ * else runs. It changes nothing while a reviewer *is* answering, and never softens a
109
+ * deterministic deny. It applies to the ordinary 自动审查 tiers (`permissive`,
110
+ * `permissive-full`); the three tiers that own an implementation (`yolo`, `safenetPass`,
111
+ * `safenetAsk`) already say who answers and are not degraded by this switch.
112
+ */
113
+ readonly rulesFallback?: boolean;
114
+ /**
115
+ * **Retired** (2026-10-17) — the per-preset mode table (`<preset id>=<mode>`) that briefly
116
+ * decided who answers an ask. Each human-free implementation now owns exactly one preset
117
+ * and exactly one switch (`yolo` → `permissive-yolo`, `safenetPass` → `permissive-mech`,
118
+ * `safenetAsk` → `permissive-snet`), which is what the table could not guarantee: entries
119
+ * could name a preset outside the gate's scope, and a stored list could claim to steer
120
+ * presets it never actually reached. The key stays in the schema **only** so a
121
+ * profile written by those builds still parses; nothing reads it, and the settings card no
122
+ * longer writes it. The replacement semantics live in `src/unattended.ts`.
123
+ */
124
+ readonly unattendedTiers?: string[];
125
+ /**
126
+ * Script review (default **on**) — when a call runs a script file (`bash x.sh`,
127
+ * `pwsh -File x.ps1`, `python x.py`, `./deploy.sh`), read the file and attach its
128
+ * body to the review: the risk grader and the human both judge the code that will
129
+ * execute instead of a filename, and the deterministic nets grade the body too (a
130
+ * script that wipes `$HOME` is irreversible whatever the command line says).
131
+ *
132
+ * Bounded (a few files, a fixed per-file budget) and redacted on the way out like
133
+ * every other egress text. Turn it off to keep file contents off the configured
134
+ * endpoint: the cost is that out-of-workspace and unreadable script runs go back to
135
+ * being graded as unknowable (the `U` tier).
136
+ */
137
+ readonly scriptReview?: boolean;
69
138
  /** Backend approval strategies; combinable. Front-end exposes only `permissive` itself. */
70
139
  readonly permissiveStrategies?: Partial<PermissiveStrategies>;
71
140
  /**
@@ -95,6 +164,50 @@ export interface PermGateConfig {
95
164
  * (possibly empty) replaces it. Editable from the settings card as a list.
96
165
  */
97
166
  readonly denyKeywords?: string[];
167
+ /**
168
+ * Keywords **appended** to whichever blacklist is live (`denyKeywords` when set, the
169
+ * preset otherwise) instead of replacing it. This is what to use to protect a
170
+ * long-tail secret of your own — a company token prefix, a vault CLI subcommand —
171
+ * without having to copy fifty preset entries into your profile and keep them current.
172
+ */
173
+ readonly denyKeywordsExtra?: string[];
174
+ /**
175
+ * Credential paths beyond the built-in store list (`.ssh`, `.aws`, `.npmrc`,
176
+ * `credentials.yaml`, …), which P0 refuses to read from outside the workspace and
177
+ * refuses to mutate through a file tool.
178
+ *
179
+ * Entries are path globs matched on the normalized path: `**` crosses directories, `*`
180
+ * and `?` stay inside one; a plain entry is a path fragment (`vault/prod.env`,
181
+ * `.myco_token`), and a `~/` prefix expands to the real home. Relative patterns also
182
+ * match at any depth, so `secrets/*.env` covers `a/b/secrets/prod.env`.
183
+ *
184
+ * Why a setting and not a rule in `rules.yml`: a `read`/`grep` call is auto-allowed a
185
+ * stage before the rule chain runs (the only layer that sees it first is P0), and the
186
+ * tier of an entry must not be the author's choice — a declared secret gets what
187
+ * `.ssh` gets, never a confirmation a model can talk its way out of.
188
+ */
189
+ readonly credentialPaths?: string[];
190
+ /**
191
+ * Argument keys to treat as **document bodies** in addition to the built-in list
192
+ * (`content`, `text`, `plan`…): their values never enter the flattened text the
193
+ * deny-keyword blacklist and the P0 credential pre-screen read.
194
+ *
195
+ * The point is the long tail of host tools whose prose keys nobody shipped a rule for
196
+ * (`steps`, `notes`, `rationale`) — the shape of GitHub issue #2, where an unlisted
197
+ * document key made a plan get refused for describing `rm -rf`. Additive only: the
198
+ * built-in list cannot be replaced, because an omission would re-create that bug.
199
+ */
200
+ readonly extraContentArgKeys?: string[];
201
+ /**
202
+ * Argument keys to treat as **operations** even when the tool class says otherwise: the
203
+ * field's text is read by the blacklist and the credential pre-screen, and this list
204
+ * wins over `extraContentArgKeys` when both name the same key.
205
+ *
206
+ * For a workflow/skill-style tool whose body *is* executed code. Cost, stated: naming a
207
+ * genuine document key here re-exposes the issue #2 failure — a plan or note that merely
208
+ * mentions a destructive command will be refused for mentioning it.
209
+ */
210
+ readonly extraOperationArgKeys?: string[];
98
211
  /**
99
212
  * One-shot marker for the preset deny-table seed (`preset-rules.ts`, ported
100
213
  * from cc-safety-net): set once the `[deny-rule:*]` entries have been merged
@@ -356,6 +469,21 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
356
469
  grantTtlMs: z<number, number, "defined">;
357
470
  grantMaxUses: z<number, number, "defined">;
358
471
  permissive: z<boolean, boolean, "defined">;
472
+ yolo: z<boolean, boolean, "defined">;
473
+ rulesFallback: z<boolean, boolean, "defined">;
474
+ /**
475
+ * Retired (2026-10-17): the per-preset mode table that briefly mapped
476
+ * `<preset id>=<mode>`. Each human-free implementation now owns exactly one preset and
477
+ * exactly one switch (`yolo` / `safenetPass` / `safenetAsk`), so presets never cross and a
478
+ * switch means what it says — whether that implementation exists. The key is kept in the
479
+ * schema only so an older profile that stored it still parses.
480
+ */
481
+ unattendedTiers: z<string[], string[], "defined">;
482
+ /** Switch: the `permissive-mech` implementation exists (mechanical takeover of asks). */
483
+ safenetPass: z<boolean, boolean, "defined">;
484
+ /** Switch: the `permissive-snet` implementation exists (mechanical screen, still asks). */
485
+ safenetAsk: z<boolean, boolean, "defined">;
486
+ scriptReview: z<boolean, boolean, "defined">;
359
487
  gatePresets: z<string[], string[], "plain">;
360
488
  permissiveStrategies: z<Schemastery.ObjectS<NoInfer<{
361
489
  trustAutoAllow: z<boolean, boolean, "defined">;
@@ -370,6 +498,10 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
370
498
  }>>, "plain">;
371
499
  allowlist: z<string[], string[], "plain">;
372
500
  denyKeywords: z<string[], string[], "plain">;
501
+ denyKeywordsExtra: z<string[], string[], "defined">;
502
+ credentialPaths: z<string[], string[], "defined">;
503
+ extraContentArgKeys: z<string[], string[], "defined">;
504
+ extraOperationArgKeys: z<string[], string[], "defined">;
373
505
  presetRulesSeeded: z<boolean, boolean, "defined">;
374
506
  autoAllowTools: z<string[], string[], "plain">;
375
507
  sessionSweep: z<boolean, boolean, "defined">;
@@ -416,6 +548,21 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
416
548
  grantTtlMs: z<number, number, "defined">;
417
549
  grantMaxUses: z<number, number, "defined">;
418
550
  permissive: z<boolean, boolean, "defined">;
551
+ yolo: z<boolean, boolean, "defined">;
552
+ rulesFallback: z<boolean, boolean, "defined">;
553
+ /**
554
+ * Retired (2026-10-17): the per-preset mode table that briefly mapped
555
+ * `<preset id>=<mode>`. Each human-free implementation now owns exactly one preset and
556
+ * exactly one switch (`yolo` / `safenetPass` / `safenetAsk`), so presets never cross and a
557
+ * switch means what it says — whether that implementation exists. The key is kept in the
558
+ * schema only so an older profile that stored it still parses.
559
+ */
560
+ unattendedTiers: z<string[], string[], "defined">;
561
+ /** Switch: the `permissive-mech` implementation exists (mechanical takeover of asks). */
562
+ safenetPass: z<boolean, boolean, "defined">;
563
+ /** Switch: the `permissive-snet` implementation exists (mechanical screen, still asks). */
564
+ safenetAsk: z<boolean, boolean, "defined">;
565
+ scriptReview: z<boolean, boolean, "defined">;
419
566
  gatePresets: z<string[], string[], "plain">;
420
567
  permissiveStrategies: z<Schemastery.ObjectS<NoInfer<{
421
568
  trustAutoAllow: z<boolean, boolean, "defined">;
@@ -430,6 +577,10 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
430
577
  }>>, "plain">;
431
578
  allowlist: z<string[], string[], "plain">;
432
579
  denyKeywords: z<string[], string[], "plain">;
580
+ denyKeywordsExtra: z<string[], string[], "defined">;
581
+ credentialPaths: z<string[], string[], "defined">;
582
+ extraContentArgKeys: z<string[], string[], "defined">;
583
+ extraOperationArgKeys: z<string[], string[], "defined">;
433
584
  presetRulesSeeded: z<boolean, boolean, "defined">;
434
585
  autoAllowTools: z<string[], string[], "plain">;
435
586
  sessionSweep: z<boolean, boolean, "defined">;
@@ -460,7 +611,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
460
611
  /** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
461
612
  export type VolatileRef<T> = Volatile<T>;
462
613
  /** The config fields marked `.volatile()` — live refs inside `apply`'s config. */
463
- export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "classifierApiKey", "riskTimeoutMs", "riskLearning", "riskSediment", "classifierSource", "classifierProvider", "riskThreshold", "grantTtlMs", "grantMaxUses", "permissive", "permissiveStrategies", "allowlist", "denyKeywords", "presetRulesSeeded", "autoAllowTools", "sessionSweep", "searchUp", "badFilePolicy", "maxChainLength", "networkEnabled", "networkMode", "networkUnlisted", "networkUnattributed", "networkLoopback", "networkBind", "networkPort", "networkNoProxy", "networkInjectEnv", "networkAskTimeoutMs", "networkGrantTtlMs", "rules", "sandboxEnabled", "sandboxMode", "sandboxProxy", "sandboxLoopback"];
614
+ export declare const VOLATILE_CONFIG_KEYS: readonly ["defaultAction", "caseInsensitivePaths", "classifierEnabled", "classifierEndpoint", "classifierModel", "classifierApiKey", "riskTimeoutMs", "riskLearning", "riskSediment", "classifierSource", "classifierProvider", "riskThreshold", "grantTtlMs", "grantMaxUses", "permissive", "yolo", "rulesFallback", "unattendedTiers", "safenetPass", "safenetAsk", "scriptReview", "permissiveStrategies", "allowlist", "denyKeywords", "denyKeywordsExtra", "credentialPaths", "extraContentArgKeys", "extraOperationArgKeys", "presetRulesSeeded", "autoAllowTools", "sessionSweep", "searchUp", "badFilePolicy", "maxChainLength", "networkEnabled", "networkMode", "networkUnlisted", "networkUnattributed", "networkLoopback", "networkBind", "networkPort", "networkNoProxy", "networkInjectEnv", "networkAskTimeoutMs", "networkGrantTtlMs", "rules", "sandboxEnabled", "sandboxMode", "sandboxProxy", "sandboxLoopback"];
464
615
  /** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
465
616
  export declare function readVolatileValue<T>(value: T | Volatile<T> | undefined): VolatileSnapshot<T> | undefined;
466
617
  /** Shallow-resolve every volatile field into a plain snapshot (one per read). */
package/lib/config.js CHANGED
@@ -193,6 +193,21 @@ export const Config = z.object({
193
193
  grantTtlMs: vol(z.number().min(1).default(5 * 60_000)),
194
194
  grantMaxUses: vol(z.number().min(1).default(1)),
195
195
  permissive: vol(z.boolean().default(false)),
196
+ yolo: vol(z.boolean().default(false)),
197
+ rulesFallback: vol(z.boolean().default(false)),
198
+ /**
199
+ * Retired (2026-10-17): the per-preset mode table that briefly mapped
200
+ * `<preset id>=<mode>`. Each human-free implementation now owns exactly one preset and
201
+ * exactly one switch (`yolo` / `safenetPass` / `safenetAsk`), so presets never cross and a
202
+ * switch means what it says — whether that implementation exists. The key is kept in the
203
+ * schema only so an older profile that stored it still parses.
204
+ */
205
+ unattendedTiers: vol(z.array(z.string()).default([])),
206
+ /** Switch: the `permissive-mech` implementation exists (mechanical takeover of asks). */
207
+ safenetPass: vol(z.boolean().default(true)),
208
+ /** Switch: the `permissive-snet` implementation exists (mechanical screen, still asks). */
209
+ safenetAsk: vol(z.boolean().default(false)),
210
+ scriptReview: vol(z.boolean().default(true)),
196
211
  gatePresets: z.array(z.string()),
197
212
  permissiveStrategies: vol(z.object({
198
213
  trustAutoAllow: z.boolean().default(true),
@@ -202,6 +217,10 @@ export const Config = z.object({
202
217
  })),
203
218
  allowlist: vol(z.array(z.string())),
204
219
  denyKeywords: vol(z.array(z.string())),
220
+ denyKeywordsExtra: vol(z.array(z.string()).default([])),
221
+ credentialPaths: vol(z.array(z.string()).default([])),
222
+ extraContentArgKeys: vol(z.array(z.string()).default([])),
223
+ extraOperationArgKeys: vol(z.array(z.string()).default([])),
205
224
  presetRulesSeeded: vol(z.boolean().default(false)),
206
225
  autoAllowTools: vol(z.array(z.string())),
207
226
  sessionSweep: vol(z.boolean().default(true)),
@@ -241,7 +260,7 @@ export const VOLATILE_CONFIG_KEYS = [
241
260
  'defaultAction', 'caseInsensitivePaths', 'classifierEnabled', 'classifierEndpoint',
242
261
  'classifierModel', 'classifierApiKey', 'riskTimeoutMs', 'riskLearning', 'riskSediment',
243
262
  'classifierSource', 'classifierProvider', 'riskThreshold', 'grantTtlMs', 'grantMaxUses',
244
- 'permissive', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'presetRulesSeeded', 'autoAllowTools',
263
+ 'permissive', 'yolo', 'rulesFallback', 'unattendedTiers', 'safenetPass', 'safenetAsk', 'scriptReview', 'permissiveStrategies', 'allowlist', 'denyKeywords', 'denyKeywordsExtra', 'credentialPaths', 'extraContentArgKeys', 'extraOperationArgKeys', 'presetRulesSeeded', 'autoAllowTools',
245
264
  'sessionSweep', 'searchUp', 'badFilePolicy', 'maxChainLength',
246
265
  'networkEnabled', 'networkMode', 'networkUnlisted', 'networkUnattributed', 'networkLoopback',
247
266
  'networkBind', 'networkPort', 'networkNoProxy', 'networkInjectEnv', 'networkAskTimeoutMs',
@@ -0,0 +1,35 @@
1
+ /**
2
+ * User-declared credential paths — the long tail the built-in list cannot know.
3
+ *
4
+ * The built-in credential set (`.ssh`, `.aws`, `.npmrc`, `credentials.yaml`, …) ships in
5
+ * `path.ts` because the layer that needs it is P0: a `read`/`grep` call is auto-allowed
6
+ * one stage before the rule chain runs, and mutating a file tool never reaches a model
7
+ * that could be talked out of it. That is also why this cannot simply be "add a rule to
8
+ * `rules.yml`" for the read side — see the note in `runtime.ts` about why the auto-allow
9
+ * does not consult the ruleset (preset entries and hand-written entries live in one
10
+ * document with no provenance, so `.dsh/**` would start blocking legitimate memory
11
+ * reads).
12
+ *
13
+ * So the *list* is what a user extends, through the `credentialPaths` setting, and the
14
+ * *tier* stays where it has to be: a listed path gets the same treatment as the
15
+ * built-ins — reading it outside the workspace is refused, writing it is refused.
16
+ *
17
+ * Matching is pure string work: no filesystem access, no resolution, no execution. A
18
+ * pattern that cannot be understood is dropped rather than guessed at — an unreadable
19
+ * entry must not silently become "protects everything".
20
+ */
21
+ /**
22
+ * Compile the configured credential paths into one predicate.
23
+ *
24
+ * Returns a matcher that answers `false` for an empty or unusable list, so callers can
25
+ * pass the raw setting straight through. `home` expands a leading `~/` the way a shell
26
+ * would, because the setting is written in home-relative form (`~/.myco/vault.token`)
27
+ * while the paths being judged are absolute. **Without `home`** the `~/` is stripped and
28
+ * the rest matched as a path fragment — a declared secret must not silently stop
29
+ * protecting because one caller did not know the user directory; the widened tail match
30
+ * only ever denies more, never less.
31
+ */
32
+ export declare function compileCredentialPaths(entries: readonly string[] | undefined, opts?: {
33
+ readonly caseInsensitive?: boolean;
34
+ readonly home?: string;
35
+ }): (path: string) => boolean;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * User-declared credential paths — the long tail the built-in list cannot know.
3
+ *
4
+ * The built-in credential set (`.ssh`, `.aws`, `.npmrc`, `credentials.yaml`, …) ships in
5
+ * `path.ts` because the layer that needs it is P0: a `read`/`grep` call is auto-allowed
6
+ * one stage before the rule chain runs, and mutating a file tool never reaches a model
7
+ * that could be talked out of it. That is also why this cannot simply be "add a rule to
8
+ * `rules.yml`" for the read side — see the note in `runtime.ts` about why the auto-allow
9
+ * does not consult the ruleset (preset entries and hand-written entries live in one
10
+ * document with no provenance, so `.dsh/**` would start blocking legitimate memory
11
+ * reads).
12
+ *
13
+ * So the *list* is what a user extends, through the `credentialPaths` setting, and the
14
+ * *tier* stays where it has to be: a listed path gets the same treatment as the
15
+ * built-ins — reading it outside the workspace is refused, writing it is refused.
16
+ *
17
+ * Matching is pure string work: no filesystem access, no resolution, no execution. A
18
+ * pattern that cannot be understood is dropped rather than guessed at — an unreadable
19
+ * entry must not silently become "protects everything".
20
+ */
21
+ /** Normalized, lowercase-able comparison form for a path or pattern. */
22
+ function norm(p, caseInsensitive) {
23
+ const slashed = p.trim().replace(/[\\/]+/g, '/');
24
+ const stripped = slashed.length > 1 ? slashed.replace(/\/+$/, '') : slashed;
25
+ return caseInsensitive ? stripped.toLowerCase() : stripped;
26
+ }
27
+ /** Escape regex metacharacters that carry no meaning in a path glob. */
28
+ function escapeRe(s) {
29
+ return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
30
+ }
31
+ /**
32
+ * Translate a path glob into a regex source.
33
+ *
34
+ * `**` is translated through a placeholder so it can be restored *after* the
35
+ * single-`*` rule ran — replacing them in the naive order turns `**` into `[^/]*[^/]*`,
36
+ * which silently stops crossing directories, i.e. `secrets/**` would match nothing.
37
+ */
38
+ function globSource(glob) {
39
+ const CROSS = '\u0000';
40
+ return escapeRe(glob)
41
+ .replace(/\*\*/g, CROSS)
42
+ .replace(/\*/g, '[^/]*')
43
+ .replace(/\?/g, '[^/]')
44
+ .split(CROSS)
45
+ .join('.*');
46
+ }
47
+ /**
48
+ * Compile one entry into a matcher over an already-normalized path.
49
+ *
50
+ * - contains `*` or `?` → glob: a double star crosses directories, a single star and
51
+ * a question mark stay inside one. A pattern that is not absolute (no leading slash,
52
+ * no drive letter) and does not already start with a crossing star is additionally
53
+ * allowed to match at any depth (`secrets/STAR.env` covers `a/b/secrets/x.env`),
54
+ * because that is what people mean when they write a relative glob.
55
+ * - no wildcard → path fragment: matches the path itself, or a whole leading/inner
56
+ * segment run (`myco/token`, `.myco_token`, `vault/prod.env`).
57
+ */
58
+ function compileEntry(raw, caseInsensitive) {
59
+ const entry = norm(raw, caseInsensitive);
60
+ if (entry === '' || entry === '/' || entry === '*')
61
+ return undefined;
62
+ const anchored = entry.startsWith('/') || /^[a-z]:/i.test(entry);
63
+ if (/[*?]/.test(entry)) {
64
+ const body = globSource(entry);
65
+ const source = anchored ? `^${body}$` : `(?:^|.*/)${body}$`;
66
+ let re;
67
+ try {
68
+ re = new RegExp(source, caseInsensitive ? 'i' : '');
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ return (path) => re.test(path);
74
+ }
75
+ // A plain entry is a path fragment, matched on segment boundaries only: `token` must
76
+ // not protect nothing while `.env` silently matches `ambient.txt`.
77
+ const withLead = entry.startsWith('.') || entry.includes('/') ? entry : `/${entry}`;
78
+ return (path) => path === entry || path.endsWith(withLead) || path.includes(`${withLead}/`) || (anchored && path.startsWith(entry));
79
+ }
80
+ /**
81
+ * Compile the configured credential paths into one predicate.
82
+ *
83
+ * Returns a matcher that answers `false` for an empty or unusable list, so callers can
84
+ * pass the raw setting straight through. `home` expands a leading `~/` the way a shell
85
+ * would, because the setting is written in home-relative form (`~/.myco/vault.token`)
86
+ * while the paths being judged are absolute. **Without `home`** the `~/` is stripped and
87
+ * the rest matched as a path fragment — a declared secret must not silently stop
88
+ * protecting because one caller did not know the user directory; the widened tail match
89
+ * only ever denies more, never less.
90
+ */
91
+ export function compileCredentialPaths(entries, opts = {}) {
92
+ const caseInsensitive = opts.caseInsensitive ?? true;
93
+ const home = opts.home;
94
+ const matchers = [];
95
+ for (const raw of entries ?? []) {
96
+ if (typeof raw !== 'string')
97
+ continue;
98
+ let text = raw.trim();
99
+ if (text === '')
100
+ continue;
101
+ if (text === '~' || text.startsWith('~/') || text.startsWith('~\\')) {
102
+ const rest = text.slice(1).replace(/^[\\/]/, '');
103
+ text = home !== undefined && home !== ''
104
+ ? `${home.replace(/[\\/]+$/, '')}/${rest}`
105
+ // No home known: keep the tail. `~/.myco/vault.token` becomes `.myco/vault.token`,
106
+ // which still matches any user directory's copy of it.
107
+ : rest;
108
+ }
109
+ const m = compileEntry(text, caseInsensitive);
110
+ if (m !== undefined)
111
+ matchers.push(m);
112
+ }
113
+ if (matchers.length === 0)
114
+ return () => false;
115
+ return (path) => {
116
+ const p = norm(path, caseInsensitive);
117
+ return p !== '' && matchers.some((m) => m(p));
118
+ };
119
+ }
package/lib/engine.d.ts CHANGED
@@ -3,13 +3,11 @@ export type EngineStage = 'hard-deny' | 'grant' | 'rule' | 'default' | 'ask' | '
3
3
  /** The built-in read-only / internal classification, for diagnostics and docs. */
4
4
  export declare const AUTO_ALLOW_TOOLS: ReadonlySet<string>;
5
5
  /**
6
- * Argument keys that carry a document body rather than the operation itself.
7
- * A file's text is not the operation: writing a document that *mentions* a
8
- * token, a private key, or `credentials.yaml` must not hard-deny the write, and
9
- * the deny-keyword layer already skips these keys. The path/command arguments
10
- * that actually describe the operation are still scanned.
6
+ * The document-body keys the screens skip live in `scan-text.ts`, shared with the
7
+ * deny-keyword layer so the two cannot drift. Re-exported here because this was their
8
+ * published home.
11
9
  */
12
- export declare const CONTENT_ARG_KEYS: ReadonlySet<string>;
10
+ export { CONTENT_ARG_KEYS } from './scan-text.js';
13
11
  /**
14
12
  * Deterministic P0 hard-deny reason, or undefined when the call may proceed to
15
13
  * later stages.
package/lib/engine.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * P4 ask official approval seam
9
9
  * Strictly fail-closed: P0 wins over every later stage.
10
10
  */
11
- import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
11
+ import { isProtectedCredentialPath, isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
12
+ import { compileCredentialPaths } from './credential-paths.js';
12
13
  import { commandIrreversibleReason, irreversibleReason } from './irreversible.js';
13
14
  import { executableVariants, normalizeCommandText } from './command-normalize.js';
14
15
  // 工具名名单归 `tool-names.ts` 所有(不得在此处再抄一份)。P0 **不看工具名白名单**:
@@ -23,46 +24,20 @@ const NO_EXTRA_AUTO_ALLOW = new Set();
23
24
  /** The built-in read-only / internal classification, for diagnostics and docs. */
24
25
  export const AUTO_ALLOW_TOOLS = new Set([...READ_TOOLS, ...INTERNAL_TOOLS]);
25
26
  /**
26
- * Argument keys that carry a document body rather than the operation itself.
27
- * A file's text is not the operation: writing a document that *mentions* a
28
- * token, a private key, or `credentials.yaml` must not hard-deny the write, and
29
- * the deny-keyword layer already skips these keys. The path/command arguments
30
- * that actually describe the operation are still scanned.
27
+ * The document-body keys the screens skip live in `scan-text.ts`, shared with the
28
+ * deny-keyword layer so the two cannot drift. Re-exported here because this was their
29
+ * published home.
31
30
  */
32
- export const CONTENT_ARG_KEYS = new Set([
33
- 'content', 'contents', 'text', 'body', 'message', 'prompt',
34
- 'new_string', 'old_string', 'newText', 'oldText', 'file_text', 'fileText',
35
- 'diff', 'patch', 'replacement', 'snippet',
36
- ]);
37
- /** The call's non-body text: the operation, not any document it carries. */
38
- function operationText(args, depth = 0) {
39
- if (depth > 6)
40
- return '';
41
- const parts = [];
42
- for (const [key, value] of Object.entries(args)) {
43
- if (typeof value === 'string') {
44
- if (!CONTENT_ARG_KEYS.has(key))
45
- parts.push(value);
46
- continue;
47
- }
48
- if (Array.isArray(value)) {
49
- for (const item of value) {
50
- if (typeof item === 'string')
51
- parts.push(item);
52
- else if (item !== null && typeof item === 'object')
53
- parts.push(operationText(item, depth + 1));
54
- }
55
- continue;
56
- }
57
- if (value !== null && typeof value === 'object') {
58
- parts.push(operationText(value, depth + 1));
59
- }
60
- }
61
- return parts.join(' ');
62
- }
31
+ export { CONTENT_ARG_KEYS } from './scan-text.js';
32
+ import { operationText } from './scan-text.js';
63
33
  /**
64
34
  * Credential or private-key material a call carries.
65
35
  *
36
+ * Read through `scan-text.operationText`, so a document tool's prose is no longer fed
37
+ * to this screen at all (issue #2): refusing `exit_plan_mode` because the plan *names*
38
+ * a step it intends to ask about is a self-contradiction — `decide()` classifies the
39
+ * same call as needing no approval one layer later.
40
+ *
66
41
  * Two clauses were removed on purpose, both because this predicate reads one flat
67
42
  * string and therefore cannot tell *using* a secret from *moving* one — and P0 is
68
43
  * the layer nobody can appeal:
@@ -78,8 +53,15 @@ function operationText(args, depth = 0) {
78
53
  * What is left is the theft shape: a secret *artifact* plus a channel out, or any
79
54
  * non-readonly verb touching one. That pairing lives in `irreversible.ts`, so both
80
55
  * layers agree.
56
+ *
57
+ * `id_` keeps its `.pub` half out: a public key is the artifact you hand *other*
58
+ * people, so writing `~/.ssh/id_ed25519.pub` (registering a key, restoring a pair by
59
+ * hand) was refused at the one layer nobody can appeal for no protective reason. The
60
+ * private halves stay in, as does `config` — an ssh config's `ProxyCommand` /
61
+ * `LocalCommand` is code that runs on the next connection, which is an execution
62
+ * plant, not a preference file.
81
63
  */
82
- const CREDENTIAL_MATERIAL = /(?:BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|\.ssh[\\/](?:id_|config)|credentials\.yaml)/i;
64
+ const CREDENTIAL_MATERIAL = /(?:BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|\.ssh[\\/]id_(?![^\\/]*\.pub(?:$|[\\/]))|\.ssh[\\/]config|credentials\.yaml)/i;
83
65
  /** Verbs that only look. A call made of nothing else cannot have leaked anything. */
84
66
  const READ_ONLY_HEAD = /^(?:test-path|test|ls|dir|get-childitem|get-content|get-item|cat|type|more|head|tail|stat|file|select-string|where|which|exists)$/i;
85
67
  /** Whether every segment of this command text is a read-only look. */
@@ -92,8 +74,13 @@ function readonlyProbe(text) {
92
74
  return READ_ONLY_HEAD.test(head);
93
75
  });
94
76
  }
95
- function containsCredentialMaterial(args) {
96
- const text = operationText(args);
77
+ /**
78
+ * Credential or private-key material a call carries, read out of the *operation* text
79
+ * only — which is `scan-text`'s job, and why the key lists are a caller input: a document
80
+ * body is somebody's prose, not a theft in progress.
81
+ */
82
+ function containsCredentialMaterial(ctx) {
83
+ const text = operationText(ctx.args, ctx.tool, ctx.scanKeys);
97
84
  return CREDENTIAL_MATERIAL.test(text) && !readonlyProbe(text);
98
85
  }
99
86
  function pathArgument(args) {
@@ -154,7 +141,7 @@ function gitRemoteRewriteReason(segments) {
154
141
  */
155
142
  export function hardDenyReason(ctx) {
156
143
  const args = ctx.args;
157
- if (containsCredentialMaterial(args))
144
+ if (containsCredentialMaterial(ctx))
158
145
  return 'call contains credential or private-key material';
159
146
  // **Shape, not a name whitelist** (see tool-names.ts): a call that carries a
160
147
  // command string and is not a known non-executor is a command call whatever the
@@ -209,9 +196,33 @@ export function hardDenyReason(ctx) {
209
196
  if (isProtectedDestructiveTarget(path, ctx.dshHome))
210
197
  return `mutation targets protected path: ${path}`;
211
198
  }
199
+ // Credential-store mutation via a file tool is an access backdoor: dropping an
200
+ // `authorized_keys` line, `git config credential.helper store`, or rewriting
201
+ // `.git-credentials`/`.npmrc`/cloud creds grants or leaks authority. The shell
202
+ // layer already denies deletion of these stores and asks on reading them, but
203
+ // write/edit reached past the P0 guard because it only screened system roots, so
204
+ // `write ~/.ssh/authorized_keys` was auto-allowed. Scoped to file-mutation tools
205
+ // (not bash/pwsh, whose real target is the command string, judged above), and
206
+ // `.env` is excluded (project-local dev config).
207
+ //
208
+ // `credentialPaths` (a setting) is consulted here and in the read branch below: the
209
+ // long tail of secrets — a company vault file, a deploy key nobody shipped in a
210
+ // preset — has to be extendable, and this is the only layer that a *read* call cannot
211
+ // skip past. The tier is not configurable on purpose: a declared path gets exactly
212
+ // what `.ssh` gets, never a downgradable `ask`.
213
+ const declared = compileCredentialPaths(ctx.credentialPaths, {
214
+ caseInsensitive: ctx.caseInsensitive ?? false,
215
+ // `userHome`, not `home`: the latter is the workspace root. A caller that does not
216
+ // supply it gets the tail-match fallback in `compileCredentialPaths`, which is
217
+ // stricter, never looser.
218
+ ...(ctx.userHome === undefined ? {} : { home: ctx.userHome }),
219
+ });
220
+ if (path !== undefined && (ctx.tool === 'write' || ctx.tool === 'edit' || DESTRUCTIVE_TOOL.test(ctx.tool)) && isProtectedCredentialPath(path, declared)) {
221
+ return `mutation targets a credential store: ${path}`;
222
+ }
212
223
  if (READ_TOOLS.has(ctx.tool) && path !== undefined) {
213
224
  const normalized = path.startsWith('/') || /^[A-Za-z]:/.test(path) ? path : path;
214
- if (!isWithin(ctx.cwd, normalized) && isSensitivePath(normalized)) {
225
+ if (!isWithin(ctx.cwd, normalized) && (isSensitivePath(normalized) || declared(path))) {
215
226
  return `read of sensitive path outside workspace: ${path}`;
216
227
  }
217
228
  }
package/lib/evaluate.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CompiledRuleEntry, type CompiledRuleset, type RuleAction } from './rule.js';
2
2
  import { type SimpleCommand } from './shell.js';
3
+ import type { ScanKeyPolicy } from './scan-text.js';
3
4
  export interface ToolCallContext {
4
5
  readonly tool: string;
5
6
  readonly args: Record<string, unknown>;
@@ -7,8 +8,27 @@ export interface ToolCallContext {
7
8
  readonly cwd: string;
8
9
  /** Workspace root; used by the write-path override to decide inside vs outside. */
9
10
  readonly home?: string;
11
+ /**
12
+ * The real user directory (`os.homedir()`), used to expand a `~/` in a declared
13
+ * credential path. Deliberately separate from `home`, which is the workspace root —
14
+ * conflating them would expand `~/.myco/vault.token` into the project and protect
15
+ * nothing at all.
16
+ */
17
+ readonly userHome?: string;
10
18
  readonly dshHome?: string;
11
19
  readonly caseInsensitive?: boolean;
20
+ /**
21
+ * User-declared credential paths (`credentialPaths` setting), raw as configured.
22
+ * P0 compiles them; they extend the built-in store list for the two checks a read or
23
+ * write call cannot route around. Absent means the built-ins alone.
24
+ */
25
+ readonly credentialPaths?: readonly string[];
26
+ /**
27
+ * Additions to the argument-key sets that decide which parameter text a protective
28
+ * screen may read (`extraContentArgKeys` / `extraOperationArgKeys`). Absent means the
29
+ * built-in sets, which is what an embedded caller or a dry run gets.
30
+ */
31
+ readonly scanKeys?: ScanKeyPolicy;
12
32
  /** Agent identity candidates from session context. */
13
33
  readonly agentCandidates?: readonly string[];
14
34
  /** Network request info (for network dimension matching). */