@yagni-app/code 1.0.5 → 1.0.7
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/README.md +89 -27
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -3
- package/dist/claudePlugins.js +129 -19
- package/dist/cli.js +28 -4
- package/dist/doctor.d.ts +42 -4
- package/dist/doctor.js +150 -15
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +18 -5
- package/dist/extension/index.js +319 -72
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +153 -0
- package/dist/extension/mcp/config.js +381 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +100 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +33 -0
- package/dist/extension/mcp/panel.js +268 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +152 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +104 -2
- package/dist/extension/permission/gate.js +387 -31
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/personas.js +5 -0
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +122 -0
- package/dist/mcpCommand.js +787 -0
- package/dist/otel.d.ts +69 -63
- package/dist/otel.js +154 -119
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +20 -3
- package/dist/extension/mcpTools.d.ts +0 -57
- package/dist/extension/mcpTools.js +0 -132
|
@@ -99,6 +99,14 @@ export interface CircuitBreakerResult {
|
|
|
99
99
|
reason?: string;
|
|
100
100
|
}
|
|
101
101
|
export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
|
|
102
|
+
export interface ParsedVerdict {
|
|
103
|
+
verdict: GuardianVerdict;
|
|
104
|
+
/** True when the strict parse failed and the lenient repair ladder salvaged it. */
|
|
105
|
+
repaired: boolean;
|
|
106
|
+
}
|
|
107
|
+
export declare function parseVerdictDetailed(raw: string): ParsedVerdict | null;
|
|
108
|
+
/** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
|
|
109
|
+
* the repaired signal use {@link parseVerdictDetailed}. */
|
|
102
110
|
export declare function parseVerdict(raw: string): GuardianVerdict | null;
|
|
103
111
|
export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
|
|
104
112
|
export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
|
|
@@ -106,11 +114,17 @@ export interface ReviewResult {
|
|
|
106
114
|
verdict: GuardianVerdict | null;
|
|
107
115
|
error?: GuardianError;
|
|
108
116
|
cost: number;
|
|
117
|
+
/** True when the verdict came from the lenient repair ladder (strict parse
|
|
118
|
+
* failed first). The verdict is real and flows through the normal
|
|
119
|
+
* allow/ask/deny handling; this flag only marks it for telemetry. */
|
|
120
|
+
repaired?: boolean;
|
|
109
121
|
/**
|
|
110
122
|
* Scrubbed + capped copy of the model output when the verdict failed to
|
|
111
|
-
* parse (`error: "malformed"`)
|
|
112
|
-
*
|
|
113
|
-
*
|
|
123
|
+
* parse (`error: "malformed"`), or of the pre-repair extracted block when
|
|
124
|
+
* the repair ladder salvaged it (`repaired: true`). Present so the sink can
|
|
125
|
+
* capture the exact failure shape either way. Never contains the raw
|
|
126
|
+
* command unredacted: `scrubSecrets` removes secret-shaped values before
|
|
127
|
+
* this is stored.
|
|
114
128
|
*/
|
|
115
129
|
rawOutput?: string;
|
|
116
130
|
}
|
|
@@ -152,14 +166,18 @@ export interface GuardianDiagnosticEvent {
|
|
|
152
166
|
outcome: GuardianOutcome | GuardianError;
|
|
153
167
|
durationMs?: number;
|
|
154
168
|
tier?: string;
|
|
169
|
+
/** True when the lenient repair ladder salvaged a broken verdict — the
|
|
170
|
+
* outcome is still the REAL verdict (allow/ask/deny); this flag marks it
|
|
171
|
+
* for telemetry so repair hit-rate is measurable. */
|
|
172
|
+
repaired?: true;
|
|
155
173
|
/** Debug-only: command hash for correlation (never the raw command). */
|
|
156
174
|
commandHash?: string;
|
|
157
175
|
/** Debug-only: the Guardian's rationale. */
|
|
158
176
|
rationale?: string;
|
|
159
177
|
/**
|
|
160
178
|
* Scrubbed + capped copy of the unparseable model output, present only for
|
|
161
|
-
* `outcome: "malformed"`. Always-on (NOT debug-gated):
|
|
162
|
-
* `scrubSecrets`-redacted and size-capped at the source.
|
|
179
|
+
* `outcome: "malformed"` or `repaired: true`. Always-on (NOT debug-gated):
|
|
180
|
+
* it is already `scrubSecrets`-redacted and size-capped at the source.
|
|
163
181
|
*/
|
|
164
182
|
rawOutput?: string;
|
|
165
183
|
}
|
|
@@ -170,6 +188,7 @@ export interface GuardianDiagnosticEvent {
|
|
|
170
188
|
export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
|
|
171
189
|
durationMs?: number;
|
|
172
190
|
tier?: string;
|
|
191
|
+
repaired?: boolean;
|
|
173
192
|
rationale?: string;
|
|
174
193
|
commandHash?: string;
|
|
175
194
|
rawOutput?: string;
|
|
@@ -128,33 +128,158 @@ export function checkCircuitBreaker(state, limits) {
|
|
|
128
128
|
}
|
|
129
129
|
return { tripped: false };
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
const VALID_RISK_LEVELS = ["low", "medium", "high", "critical"];
|
|
132
|
+
/** Validate an already-parsed object into a verdict. The SINGLE validation
|
|
133
|
+
* gate: strict and repaired parses both end here, so the enum checks stay
|
|
134
|
+
* exactly as strict after repair as before. */
|
|
135
|
+
function validateVerdictObject(parsed) {
|
|
136
|
+
if (!parsed)
|
|
137
|
+
return null;
|
|
138
|
+
const outcome = parsed.outcome;
|
|
139
|
+
if (outcome !== "allow" && outcome !== "ask" && outcome !== "deny")
|
|
140
|
+
return null;
|
|
141
|
+
const riskLevel = parsed.riskLevel;
|
|
142
|
+
return {
|
|
143
|
+
outcome,
|
|
144
|
+
riskLevel: typeof riskLevel === "string" && VALID_RISK_LEVELS.includes(riskLevel)
|
|
145
|
+
? riskLevel
|
|
146
|
+
: "medium",
|
|
147
|
+
rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
|
|
148
|
+
? parsed.rationale.trim()
|
|
149
|
+
: "No rationale provided.",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Extract the first {...} block (efficient-tier models may wrap JSON in
|
|
153
|
+
* markdown fences despite instructions to output raw JSON). */
|
|
154
|
+
function extractJsonBlock(raw) {
|
|
155
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
156
|
+
return jsonMatch ? jsonMatch[0] : raw;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The lenient repair ladder — applied ONLY after a strict `JSON.parse` of the
|
|
160
|
+
* extracted block has already thrown. Each rung fixes one observed model
|
|
161
|
+
* failure shape and immediately retries the strict parse; the ladder runs in
|
|
162
|
+
* cheapest-first order and the content-synthesizing re-quote is always LAST.
|
|
163
|
+
* Every rung is end-anchored or last-occurrence-anchored and linear-time.
|
|
164
|
+
* Returns the repaired JSON string (parsable) or null if no rung fits.
|
|
165
|
+
*
|
|
166
|
+
* Observed shapes (captured live on deepseek-v4-flash, all previously "unclear
|
|
167
|
+
* verdict"): illegal backslash escapes (\d written literally), a doubled
|
|
168
|
+
* closing quote, and unescaped quotes inside the rationale. The trailing
|
|
169
|
+
* comma is the most common LLM JSON slip in the wild, not (yet) observed here.
|
|
170
|
+
*
|
|
171
|
+
* What the ladder deliberately does NOT fix (all fail closed):
|
|
172
|
+
* single quotes as structure (ambiguous with English apostrophes), glued
|
|
173
|
+
* multi-object output (never pick one verdict of two), raw newlines inside
|
|
174
|
+
* strings (indistinguishable from legal pretty-printed whitespace without
|
|
175
|
+
* parsing), a broken prefix before the rationale anchor, and truncations.
|
|
176
|
+
*/
|
|
177
|
+
function repairVerdictJson(s) {
|
|
178
|
+
// The ladder is SEQUENTIAL: each rung transforms the previous result and
|
|
179
|
+
// immediately retries the strict parse. Composing matters — a blob can
|
|
180
|
+
// carry more than one failure shape (an illegal backslash escape AND a
|
|
181
|
+
// trailing comma, say), and each rung alone would leave the other broken.
|
|
182
|
+
let cur = s;
|
|
183
|
+
// Rung 1 — illegal backslash escapes (\d, \w, \( … become \\d). Legal JSON
|
|
184
|
+
// escapes (" \\ \/ b f n r t and \uXXXX) are left untouched by the lookahead.
|
|
185
|
+
const escapedBackslashes = cur.replace(/\\(?!["\\\/bfnrtu])/g, "\\\\");
|
|
186
|
+
if (escapedBackslashes !== cur) {
|
|
187
|
+
try {
|
|
188
|
+
JSON.parse(escapedBackslashes);
|
|
189
|
+
return escapedBackslashes;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
cur = escapedBackslashes;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Rung 2 — doubled closing quote before }: anchored to END so a doubled
|
|
196
|
+
// quote mid-rationale is untouched (the re-quote rung handles that shape).
|
|
197
|
+
const collapsedQuote = cur.replace(/""(\s*\})$/, '"$1');
|
|
198
|
+
if (collapsedQuote !== cur) {
|
|
199
|
+
try {
|
|
200
|
+
JSON.parse(collapsedQuote);
|
|
201
|
+
return collapsedQuote;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
cur = collapsedQuote;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Rung 3 — trailing comma before }: anchored to END so a comma inside a
|
|
208
|
+
// rationale value ("a, b") can never be stripped.
|
|
209
|
+
const strippedComma = cur.replace(/,(\s*\})$/, '$1');
|
|
210
|
+
if (strippedComma !== cur) {
|
|
211
|
+
try {
|
|
212
|
+
JSON.parse(strippedComma);
|
|
213
|
+
return strippedComma;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
cur = strippedComma;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Rung 4 (last resort, the only rung that synthesizes content) — re-quote
|
|
220
|
+
// the trailing rationale value. Applies only when the shape is
|
|
221
|
+
// {…"rationale":"<rest-to-end>}: the prefix before the anchor must carry no
|
|
222
|
+
// closing brace (rules out prose and glued multi-object output — an earlier
|
|
223
|
+
// sibling object always leaves a } behind), and the extracted rationale text
|
|
224
|
+
// must carry no braces either (rules out a glued TAIL). Every " and \ in the
|
|
225
|
+
// tail is escaped in ONE pass so whatever the model wrote inside the
|
|
226
|
+
// rationale becomes literal text. Anchor ambiguity resolves to the LAST
|
|
227
|
+
// "rationale":" occurrence; single pass, no loops.
|
|
228
|
+
const anchor = cur.lastIndexOf('"rationale":"');
|
|
229
|
+
if (anchor !== -1) {
|
|
230
|
+
const prefix = cur.slice(0, anchor);
|
|
231
|
+
const tail = cur.slice(anchor + '"rationale":"'.length);
|
|
232
|
+
// The tail must be the trailing value closing the object: non-empty
|
|
233
|
+
// content, an optional-whitespace + } at the very end.
|
|
234
|
+
const tailMatch = tail.match(/^(.+?)(\s*\})$/);
|
|
235
|
+
if (tailMatch && !prefix.includes("}")) {
|
|
236
|
+
const rawRationale = tailMatch[1].replace(/"+$/, "");
|
|
237
|
+
if (rawRationale.length > 0 &&
|
|
238
|
+
!rawRationale.includes("{") &&
|
|
239
|
+
!rawRationale.includes("}")) {
|
|
240
|
+
const reQuoted = prefix +
|
|
241
|
+
'"rationale":"' +
|
|
242
|
+
rawRationale.replace(/["\\]/g, "\\$&") +
|
|
243
|
+
'"}';
|
|
244
|
+
try {
|
|
245
|
+
JSON.parse(reQuoted);
|
|
246
|
+
return reQuoted;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* fail closed */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
export function parseVerdictDetailed(raw) {
|
|
257
|
+
const jsonStr = extractJsonBlock(raw);
|
|
133
258
|
try {
|
|
134
|
-
// Efficient-tier models may wrap JSON in markdown fences despite
|
|
135
|
-
// instructions to output raw JSON. Extract the first {...} block.
|
|
136
|
-
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
137
|
-
const jsonStr = jsonMatch ? jsonMatch[0] : raw;
|
|
138
259
|
const parsed = JSON.parse(jsonStr);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
return null;
|
|
142
|
-
const riskLevel = parsed.riskLevel;
|
|
143
|
-
const validLevels = ["low", "medium", "high", "critical"];
|
|
144
|
-
return {
|
|
145
|
-
outcome,
|
|
146
|
-
riskLevel: typeof riskLevel === "string" && validLevels.includes(riskLevel)
|
|
147
|
-
? riskLevel
|
|
148
|
-
: "medium",
|
|
149
|
-
rationale: typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0
|
|
150
|
-
? parsed.rationale.trim()
|
|
151
|
-
: "No rationale provided.",
|
|
152
|
-
};
|
|
260
|
+
const verdict = validateVerdictObject(parsed);
|
|
261
|
+
return verdict ? { verdict, repaired: false } : null;
|
|
153
262
|
}
|
|
154
263
|
catch {
|
|
155
|
-
|
|
264
|
+
// Strict parse failed — try the repair ladder on the same extracted block.
|
|
265
|
+
const repairedJson = repairVerdictJson(jsonStr);
|
|
266
|
+
if (repairedJson === null)
|
|
267
|
+
return null;
|
|
268
|
+
try {
|
|
269
|
+
const parsed = JSON.parse(repairedJson);
|
|
270
|
+
const verdict = validateVerdictObject(parsed);
|
|
271
|
+
return verdict ? { verdict, repaired: true } : null;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
156
276
|
}
|
|
157
277
|
}
|
|
278
|
+
/** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
|
|
279
|
+
* the repaired signal use {@link parseVerdictDetailed}. */
|
|
280
|
+
export function parseVerdict(raw) {
|
|
281
|
+
return parseVerdictDetailed(raw)?.verdict ?? null;
|
|
282
|
+
}
|
|
158
283
|
// --- /cost subtotal ---
|
|
159
284
|
export function formatGuardianSubtotal(state, limits) {
|
|
160
285
|
if (state.reviews === 0)
|
|
@@ -236,8 +361,8 @@ export async function reviewCommand(command, deps) {
|
|
|
236
361
|
}
|
|
237
362
|
return { verdict: null, error: "empty", cost };
|
|
238
363
|
}
|
|
239
|
-
const
|
|
240
|
-
if (!
|
|
364
|
+
const parsed = parseVerdictDetailed(output);
|
|
365
|
+
if (!parsed) {
|
|
241
366
|
// Scrubbed + capped so the local sink and Sentry can see the exact
|
|
242
367
|
// failure shape without carrying a raw command or a secret it echoed.
|
|
243
368
|
return {
|
|
@@ -247,7 +372,19 @@ export async function reviewCommand(command, deps) {
|
|
|
247
372
|
rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
|
|
248
373
|
};
|
|
249
374
|
}
|
|
250
|
-
|
|
375
|
+
if (parsed.repaired) {
|
|
376
|
+
// The repair ladder salvaged a broken-but-salvageable verdict. The
|
|
377
|
+
// verdict is real (validation is exactly as strict as the happy path);
|
|
378
|
+
// capture the pre-repair shape so telemetry can keep watching what the
|
|
379
|
+
// model is still emitting wrong.
|
|
380
|
+
return {
|
|
381
|
+
verdict: parsed.verdict,
|
|
382
|
+
cost,
|
|
383
|
+
repaired: true,
|
|
384
|
+
rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { verdict: parsed.verdict, cost };
|
|
251
388
|
}
|
|
252
389
|
catch (err) {
|
|
253
390
|
// Distinguish the caller aborting (user hit ESC — must NOT be treated as
|
|
@@ -275,6 +412,7 @@ export function buildDiagnosticEvent(outcome, opts) {
|
|
|
275
412
|
outcome,
|
|
276
413
|
...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
|
|
277
414
|
...(opts.tier !== undefined ? { tier: opts.tier } : {}),
|
|
415
|
+
...(opts.repaired ? { repaired: true } : {}),
|
|
278
416
|
...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
|
|
279
417
|
};
|
|
280
418
|
if (opts.debug) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated bash command → file-operation table.
|
|
3
|
+
*
|
|
4
|
+
* The bridge that makes Read/Edit deny rules bite on bash commands: for a
|
|
5
|
+
* curated set of commands, extract the file paths they touch, classify each
|
|
6
|
+
* touch as read or write, and run those paths through the same path-rule
|
|
7
|
+
* engine. `cat .env`, `sed -i .env`, `cp .env x`, `echo x > .env` are all
|
|
8
|
+
* caught by a `Read(.env)`/`Edit(.env)` deny.
|
|
9
|
+
*
|
|
10
|
+
* Ported from Claude Code's BashTool/pathValidation.ts (PATH_EXTRACTORS +
|
|
11
|
+
* COMMAND_OPERATION_TYPE), adapted to shellParse tokens. Guardrails (same
|
|
12
|
+
* posture as Claude Code):
|
|
13
|
+
* - unknown flags on commands where flags can change path meaning
|
|
14
|
+
* (mv/cp --target-directory) → the command is NOT auto-analyzed; it
|
|
15
|
+
* degrades to the caller's "ask" path, never silently allowed.
|
|
16
|
+
* - glob arguments in write position → not analyzed (write globs bypass
|
|
17
|
+
* checks in Claude Code; we degrade to ask).
|
|
18
|
+
* - anything unextractable → no opinion; caller decides.
|
|
19
|
+
*/
|
|
20
|
+
export type FileOperation = "read" | "write";
|
|
21
|
+
/** A command's opinion: extracted (op, paths) or needs-ask. */
|
|
22
|
+
export type BashFileArgsResult = {
|
|
23
|
+
kind: "analyzed";
|
|
24
|
+
operation: FileOperation;
|
|
25
|
+
paths: string[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: "unknown";
|
|
28
|
+
};
|
|
29
|
+
/** Commands whose file paths we can extract confidently. */
|
|
30
|
+
export declare const COMMAND_OPERATION: Record<string, FileOperation>;
|
|
31
|
+
/**
|
|
32
|
+
* Analyze one bash SUBCOMMAND string. `kind: "unknown"` means no confident
|
|
33
|
+
* opinion — the caller degrades to ask for deny-evaluation purposes (never
|
|
34
|
+
* to silent allow).
|
|
35
|
+
*/
|
|
36
|
+
export declare function analyzeSubcommand(subcommand: string): BashFileArgsResult;
|
|
37
|
+
/** Split a full command into subcommand strings (reuses shellRules' splitter). */
|
|
38
|
+
export { splitSubcommands } from "./shellRules.js";
|
|
39
|
+
//# sourceMappingURL=bashFileArgs.d.ts.map
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated bash command → file-operation table.
|
|
3
|
+
*
|
|
4
|
+
* The bridge that makes Read/Edit deny rules bite on bash commands: for a
|
|
5
|
+
* curated set of commands, extract the file paths they touch, classify each
|
|
6
|
+
* touch as read or write, and run those paths through the same path-rule
|
|
7
|
+
* engine. `cat .env`, `sed -i .env`, `cp .env x`, `echo x > .env` are all
|
|
8
|
+
* caught by a `Read(.env)`/`Edit(.env)` deny.
|
|
9
|
+
*
|
|
10
|
+
* Ported from Claude Code's BashTool/pathValidation.ts (PATH_EXTRACTORS +
|
|
11
|
+
* COMMAND_OPERATION_TYPE), adapted to shellParse tokens. Guardrails (same
|
|
12
|
+
* posture as Claude Code):
|
|
13
|
+
* - unknown flags on commands where flags can change path meaning
|
|
14
|
+
* (mv/cp --target-directory) → the command is NOT auto-analyzed; it
|
|
15
|
+
* degrades to the caller's "ask" path, never silently allowed.
|
|
16
|
+
* - glob arguments in write position → not analyzed (write globs bypass
|
|
17
|
+
* checks in Claude Code; we degrade to ask).
|
|
18
|
+
* - anything unextractable → no opinion; caller decides.
|
|
19
|
+
*/
|
|
20
|
+
import { shellParse } from "../permission/execPolicy.js";
|
|
21
|
+
/** Commands whose file paths we can extract confidently. */
|
|
22
|
+
export const COMMAND_OPERATION = {
|
|
23
|
+
// read-only
|
|
24
|
+
cat: "read", head: "read", tail: "read", sort: "read", uniq: "read",
|
|
25
|
+
wc: "read", cut: "read", paste: "read", column: "read", file: "read",
|
|
26
|
+
stat: "read", diff: "read", awk: "read", strings: "read", hexdump: "read",
|
|
27
|
+
od: "read", base64: "read", nl: "read", grep: "read", rg: "read",
|
|
28
|
+
sha256sum: "read", sha1sum: "read", md5sum: "read", jq: "read",
|
|
29
|
+
ls: "read", find: "read", git: "read",
|
|
30
|
+
// mutating on their targets
|
|
31
|
+
rm: "write", rmdir: "write", mv: "write", cp: "write", touch: "write",
|
|
32
|
+
mkdir: "write", sed: "write", tee: "write",
|
|
33
|
+
};
|
|
34
|
+
// --- token helpers (over shellParse output) ---
|
|
35
|
+
function argTokens(command) {
|
|
36
|
+
const parsed = shellParse(command);
|
|
37
|
+
const tokens = [];
|
|
38
|
+
const redirects = [];
|
|
39
|
+
for (const t of parsed) {
|
|
40
|
+
if (typeof t === "string") {
|
|
41
|
+
tokens.push(t);
|
|
42
|
+
}
|
|
43
|
+
else if ("op" in t) {
|
|
44
|
+
if (t.op === "redirect" && t.direction === "out" && typeof t.target === "string") {
|
|
45
|
+
redirects.push(t.target);
|
|
46
|
+
}
|
|
47
|
+
else if (t.op !== "background") {
|
|
48
|
+
// substitution / in-redirect / background: can't analyze confidently
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { tokens, redirects };
|
|
54
|
+
}
|
|
55
|
+
/** Drop flag tokens, honoring `--` end-of-options (Claude's filterOutFlags). */
|
|
56
|
+
function filterOutFlags(args) {
|
|
57
|
+
const out = [];
|
|
58
|
+
let afterDoubleDash = false;
|
|
59
|
+
for (const a of args) {
|
|
60
|
+
if (!afterDoubleDash && a === "--") {
|
|
61
|
+
afterDoubleDash = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!afterDoubleDash && a.startsWith("-"))
|
|
65
|
+
continue;
|
|
66
|
+
out.push(a);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Pattern-first commands (grep/rg/awk/sed-style): first positional is the pattern. */
|
|
71
|
+
function patternCommandPaths(args, flagsWithValues, defaults) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let skipNext = false;
|
|
74
|
+
let patternFound = false;
|
|
75
|
+
let afterDoubleDash = false;
|
|
76
|
+
for (const a of args) {
|
|
77
|
+
if (skipNext) {
|
|
78
|
+
skipNext = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!afterDoubleDash && a === "--") {
|
|
82
|
+
afterDoubleDash = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!afterDoubleDash && a.startsWith("-")) {
|
|
86
|
+
const flag = a.split("=")[0];
|
|
87
|
+
if (flagsWithValues.has(flag) && !a.includes("="))
|
|
88
|
+
skipNext = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!patternFound) {
|
|
92
|
+
patternFound = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out.push(a);
|
|
96
|
+
}
|
|
97
|
+
return out.length > 0 ? out : defaults;
|
|
98
|
+
}
|
|
99
|
+
const GREP_FLAGS = new Set(["-e", "--regexp", "-f", "--file", "--exclude", "--include", "--exclude-dir", "--include-dir", "-m", "--max-count", "-A", "--after-context", "-B", "--before-context", "-C", "--context"]);
|
|
100
|
+
const RG_FLAGS = new Set(["-e", "--regexp", "-f", "--file", "-t", "--type", "-T", "--type-not", "-g", "--glob", "-m", "--max-count", "--max-depth", "-r", "--replace", "-A", "--after-context", "-B", "--before-context", "-C", "--context"]);
|
|
101
|
+
// mv/cp validators: ANY flag → unknown (Claude blocks all flags for these —
|
|
102
|
+
// --target-directory=PATH changes path meaning).
|
|
103
|
+
function hasFlag(args) {
|
|
104
|
+
let afterDoubleDash = false;
|
|
105
|
+
for (const a of args) {
|
|
106
|
+
if (!afterDoubleDash && a === "--") {
|
|
107
|
+
afterDoubleDash = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!afterDoubleDash && a.startsWith("-") && a !== "-")
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const GLOBISH = /[*?[]/;
|
|
116
|
+
/**
|
|
117
|
+
* Analyze one bash SUBCOMMAND string. `kind: "unknown"` means no confident
|
|
118
|
+
* opinion — the caller degrades to ask for deny-evaluation purposes (never
|
|
119
|
+
* to silent allow).
|
|
120
|
+
*/
|
|
121
|
+
export function analyzeSubcommand(subcommand) {
|
|
122
|
+
const parsed = argTokens(subcommand);
|
|
123
|
+
if (!parsed || parsed.tokens.length === 0)
|
|
124
|
+
return { kind: "unknown" };
|
|
125
|
+
// Redirect targets are ALWAYS write access, regardless of the command
|
|
126
|
+
// being curated (Claude Code validates redirect targets independently of
|
|
127
|
+
// the command table). `echo x > .env` must not escape the .env deny.
|
|
128
|
+
if (parsed.redirects.length > 0) {
|
|
129
|
+
const [cmd, ...args] = parsed.tokens;
|
|
130
|
+
const operation = COMMAND_OPERATION[cmd];
|
|
131
|
+
const paths = operation
|
|
132
|
+
? extractPaths(cmd, args)
|
|
133
|
+
: [];
|
|
134
|
+
if (paths === null)
|
|
135
|
+
return { kind: "unknown" };
|
|
136
|
+
const base = paths.filter((p) => p.length > 0);
|
|
137
|
+
if (operation === "write" && base.some((p) => GLOBISH.test(p)))
|
|
138
|
+
return { kind: "unknown" };
|
|
139
|
+
return { kind: "analyzed", operation: "write", paths: [...base, ...parsed.redirects] };
|
|
140
|
+
}
|
|
141
|
+
const [cmd, ...args] = parsed.tokens;
|
|
142
|
+
const operation = COMMAND_OPERATION[cmd];
|
|
143
|
+
if (!operation)
|
|
144
|
+
return { kind: "unknown" };
|
|
145
|
+
const paths = extractPaths(cmd, args);
|
|
146
|
+
if (paths === null)
|
|
147
|
+
return { kind: "unknown" };
|
|
148
|
+
if (operation === "write" && paths.some((p) => GLOBISH.test(p))) {
|
|
149
|
+
return { kind: "unknown" };
|
|
150
|
+
}
|
|
151
|
+
return { kind: "analyzed", operation, paths: paths.filter((p) => p.length > 0) };
|
|
152
|
+
}
|
|
153
|
+
function extractPaths(cmd, args) {
|
|
154
|
+
switch (cmd) {
|
|
155
|
+
case "grep":
|
|
156
|
+
return patternCommandPaths(args, GREP_FLAGS, []);
|
|
157
|
+
case "rg":
|
|
158
|
+
return patternCommandPaths(args, RG_FLAGS, ["."]);
|
|
159
|
+
case "ls": {
|
|
160
|
+
const out = filterOutFlags(args);
|
|
161
|
+
return out.length > 0 ? out : ["."];
|
|
162
|
+
}
|
|
163
|
+
case "find": {
|
|
164
|
+
// collect positionals until the first real flag
|
|
165
|
+
const out = [];
|
|
166
|
+
let hitFlag = false;
|
|
167
|
+
for (const a of args) {
|
|
168
|
+
if (a.startsWith("-")) {
|
|
169
|
+
hitFlag = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!hitFlag)
|
|
173
|
+
out.push(a);
|
|
174
|
+
}
|
|
175
|
+
return out.length > 0 ? out : ["."];
|
|
176
|
+
}
|
|
177
|
+
case "git":
|
|
178
|
+
// Only `git diff --no-index` reaches arbitrary files outside the repo;
|
|
179
|
+
// other git subcommands are covered by the repo's own boundaries.
|
|
180
|
+
if (args[0] === "diff" && args.includes("--no-index")) {
|
|
181
|
+
return filterOutFlags(args.slice(1)).slice(0, 2);
|
|
182
|
+
}
|
|
183
|
+
return [];
|
|
184
|
+
case "sed": {
|
|
185
|
+
// -f FILE reads a script file; positionals after the script are files.
|
|
186
|
+
const out = [];
|
|
187
|
+
let skipNext = false;
|
|
188
|
+
let scriptFound = false;
|
|
189
|
+
let afterDoubleDash = false;
|
|
190
|
+
for (const a of args) {
|
|
191
|
+
if (skipNext) {
|
|
192
|
+
skipNext = false;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!afterDoubleDash && a === "--") {
|
|
196
|
+
afterDoubleDash = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!afterDoubleDash && a.startsWith("-")) {
|
|
200
|
+
if (a === "-f" || a === "--file") {
|
|
201
|
+
const f = args[args.indexOf(a) + 1];
|
|
202
|
+
if (f)
|
|
203
|
+
out.push(f);
|
|
204
|
+
skipNext = true;
|
|
205
|
+
scriptFound = true;
|
|
206
|
+
}
|
|
207
|
+
else if (a === "-e" || a === "--expression") {
|
|
208
|
+
skipNext = true;
|
|
209
|
+
scriptFound = true;
|
|
210
|
+
}
|
|
211
|
+
else if (a.includes("e") || a.includes("f")) {
|
|
212
|
+
scriptFound = true;
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!scriptFound) {
|
|
217
|
+
scriptFound = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
out.push(a);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
case "mv":
|
|
225
|
+
case "cp":
|
|
226
|
+
// ANY flag → unknown (--target-directory=PATH changes path meaning).
|
|
227
|
+
if (hasFlag(args))
|
|
228
|
+
return null;
|
|
229
|
+
return filterOutFlags(args);
|
|
230
|
+
default:
|
|
231
|
+
return filterOutFlags(args);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Split a full command into subcommand strings (reuses shellRules' splitter). */
|
|
235
|
+
export { splitSubcommands } from "./shellRules.js";
|
|
236
|
+
//# sourceMappingURL=bashFileArgs.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-rule evaluation engine.
|
|
3
|
+
*
|
|
4
|
+
* The single entry the gate calls BEFORE hooks/exec-policy/grants/Guardian:
|
|
5
|
+
*
|
|
6
|
+
* evaluateRules(rules, { toolName, params, cwd, isProjectTrusted, ... })
|
|
7
|
+
* → { verdict: "deny" | "ask" | "allow", rule } | null (no opinion)
|
|
8
|
+
*
|
|
9
|
+
* Evaluation order is Claude Code's: for each behavior in deny → ask → allow,
|
|
10
|
+
* find the first rule that matches the tool call. Deny/ask are FINAL from the
|
|
11
|
+
* gate's perspective (nothing downstream may override); allow short-circuits
|
|
12
|
+
* the Guardian but CANNOT lift the exec-policy forbidden band or the
|
|
13
|
+
* alwaysConfirmTools contract (those floors live in the gate wiring, which
|
|
14
|
+
* re-checks them after an allow verdict).
|
|
15
|
+
*
|
|
16
|
+
* Matching by tool:
|
|
17
|
+
* - bash: Bash(command-pattern): prefix, :* suffix, or * wildcards; compound split
|
|
18
|
+
* - read/edit/write: Read(path)/Edit(path) — gitignore path patterns
|
|
19
|
+
* (read-class rules also consult grep/find/ls paths)
|
|
20
|
+
* - web_fetch: WebFetch(domain:pattern) — hostname globs
|
|
21
|
+
* - others: bare tool-name rules (and mcp__server / mcp__server__tool)
|
|
22
|
+
*
|
|
23
|
+
* Edit-class deny also applies to read access of the same path (Claude Code:
|
|
24
|
+
* a Read deny blocks Edit/Write on the path; an Edit deny blocks reads too —
|
|
25
|
+
* "edit access implies read access" cuts both ways for denies).
|
|
26
|
+
*/
|
|
27
|
+
import type { PermissionRule, RuleSource } from "./loadConfig.js";
|
|
28
|
+
export type RuleVerdict = "deny" | "ask" | "allow";
|
|
29
|
+
export interface RuleEvaluationContext {
|
|
30
|
+
toolName: string;
|
|
31
|
+
params: Record<string, unknown>;
|
|
32
|
+
cwd: string;
|
|
33
|
+
isProjectTrusted: boolean;
|
|
34
|
+
/** ~/.yagni-code (user `/`-anchor base). */
|
|
35
|
+
userStateHome: string;
|
|
36
|
+
/** Project root for project-source `/`-anchored patterns; null outside a repo. */
|
|
37
|
+
projectRoot: string | null;
|
|
38
|
+
/** Overrides ~ expansion (tests); defaults to os.homedir(). */
|
|
39
|
+
homeDir?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RuleEvaluation {
|
|
42
|
+
verdict: RuleVerdict;
|
|
43
|
+
rule: PermissionRule;
|
|
44
|
+
/** Which matcher produced the hit (verdict logs / deny messages). */
|
|
45
|
+
matchedVia: "tool_name" | "bash_pattern" | "path_pattern" | "domain_pattern" | "file_args";
|
|
46
|
+
}
|
|
47
|
+
export declare function evaluateRules(rules: readonly PermissionRule[], ctx: RuleEvaluationContext): RuleEvaluation | null;
|
|
48
|
+
/** Parse a raw rule string into a sourced PermissionRule (loader/tests). */
|
|
49
|
+
export declare function ruleFromRaw(raw: string, behavior: PermissionRule["behavior"], source: RuleSource): PermissionRule;
|
|
50
|
+
//# sourceMappingURL=engine.d.ts.map
|