@gotgenes/pi-permission-system 16.1.0 → 16.2.1
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 +28 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +57 -0
- package/src/access-intent/bash/command-enumeration.ts +79 -2
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/access-intent/bash/program.ts +3 -0
- package/src/builtin-tool-input-formatters.ts +1 -1
- package/src/config-loader.ts +7 -7
- package/src/forwarded-permissions/permission-forwarder.ts +1 -1
- package/src/handlers/gates/bash-command.ts +32 -3
- package/src/handlers/gates/bash-external-directory.ts +1 -1
- package/src/handlers/gates/bash-path.ts +7 -5
- package/src/handlers/gates/external-directory-policy.ts +9 -8
- package/src/handlers/gates/path.ts +6 -5
- package/src/handlers/gates/runner.ts +6 -5
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +7 -6
- package/src/handlers/permission-gate-handler.ts +1 -2
- package/src/handlers/tool-call-boundary.ts +1 -2
- package/src/input-normalizer.ts +1 -1
- package/src/mcp-targets.ts +1 -1
- package/src/normalize.ts +1 -1
- package/src/path-utils.ts +1 -1
- package/src/permission-event-rpc.ts +4 -6
- package/src/permission-manager.ts +40 -56
- package/src/permission-prompts.ts +1 -1
- package/src/permission-resolver.ts +45 -57
- package/src/permissions-service.ts +2 -4
- package/src/policy-loader.ts +2 -2
- package/src/skill-prompt-sanitizer.ts +2 -2
- package/src/tool-input-prompt-formatters.ts +1 -1
- package/src/tool-preview-formatter.ts +1 -1
- package/src/tool-registry.ts +1 -1
- package/src/{common.ts → value-guards.ts} +0 -66
- package/src/yaml-frontmatter.ts +65 -0
- package/test/access-intent/bash/node-text.test.ts +1 -0
- package/test/access-intent/bash/program.test.ts +67 -0
- package/test/handlers/before-agent-start.test.ts +6 -9
- package/test/handlers/external-directory-integration.test.ts +40 -153
- package/test/handlers/external-directory-session-dedup.test.ts +38 -286
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
- package/test/handlers/gates/bash-command.test.ts +89 -23
- package/test/handlers/gates/bash-external-directory.test.ts +37 -33
- package/test/handlers/gates/bash-path.test.ts +13 -9
- package/test/handlers/gates/external-directory-policy.test.ts +34 -22
- package/test/handlers/gates/external-directory.test.ts +11 -5
- package/test/handlers/gates/path.test.ts +30 -25
- package/test/handlers/gates/runner.test.ts +6 -1
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -2
- package/test/handlers/input.test.ts +1 -1
- package/test/helpers/external-directory-fixtures.ts +269 -0
- package/test/helpers/gate-fixtures.ts +12 -24
- package/test/helpers/handler-fixtures.ts +21 -15
- package/test/helpers/session-fixtures.ts +3 -18
- package/test/permission-event-rpc.test.ts +24 -20
- package/test/permission-manager-unified.test.ts +445 -209
- package/test/permission-resolver.test.ts +112 -89
- package/test/permissions-service.test.ts +10 -7
- package/test/skill-prompt-sanitizer.test.ts +30 -7
- package/test/{common.test.ts → value-guards.test.ts} +2 -96
- package/test/yaml-frontmatter.test.ts +91 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import type { ResolvedAccessIntent } from "./access-intent/access-intent";
|
|
3
3
|
import {
|
|
4
4
|
getGlobalConfigPath,
|
|
5
5
|
getProjectAgentsDir,
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
PermissionCheckResult,
|
|
28
28
|
PermissionState,
|
|
29
29
|
} from "./types";
|
|
30
|
+
import { isPermissionState } from "./value-guards";
|
|
30
31
|
|
|
31
32
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
32
33
|
"bash",
|
|
@@ -50,7 +51,7 @@ type FileCacheEntry<TValue> = {
|
|
|
50
51
|
type ResolvedPermissions = {
|
|
51
52
|
/**
|
|
52
53
|
* Fully composed ruleset: synthesized defaults → baseline → config.
|
|
53
|
-
* Session rules are appended at call-time inside
|
|
54
|
+
* Session rules are appended at call-time inside check().
|
|
54
55
|
*/
|
|
55
56
|
composedRules: Ruleset;
|
|
56
57
|
};
|
|
@@ -62,25 +63,16 @@ type ResolvedPermissions = {
|
|
|
62
63
|
*/
|
|
63
64
|
export interface ScopedPermissionManager {
|
|
64
65
|
configureForCwd(cwd: string | undefined | null): void;
|
|
65
|
-
checkPermission(
|
|
66
|
-
toolName: string,
|
|
67
|
-
input: unknown,
|
|
68
|
-
agentName?: string,
|
|
69
|
-
sessionRules?: Ruleset,
|
|
70
|
-
): PermissionCheckResult;
|
|
71
66
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* to `path`.
|
|
67
|
+
* Unified resolution entry point (Phase 6 Step 6, #478).
|
|
68
|
+
*
|
|
69
|
+
* Replaces the former `checkPermission` + `checkPathPolicy` method pair with
|
|
70
|
+
* a single dispatched call, making it structurally impossible to stub one
|
|
71
|
+
* method and forget the other (the #393 false-green class).
|
|
78
72
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
agentName?: string,
|
|
73
|
+
check(
|
|
74
|
+
intent: ResolvedAccessIntent,
|
|
82
75
|
sessionRules?: Ruleset,
|
|
83
|
-
surface?: string,
|
|
84
76
|
): PermissionCheckResult;
|
|
85
77
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
86
78
|
getConfigIssues(agentName?: string): string[];
|
|
@@ -243,56 +235,48 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
243
235
|
return evaluate(normalizedToolName, "*", composedRules).action;
|
|
244
236
|
}
|
|
245
237
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Unified resolution entry point — dispatches on intent kind.
|
|
240
|
+
*
|
|
241
|
+
* `"tool"` → normalizes raw input through `normalizeInput`.
|
|
242
|
+
* `"path-values"` → evaluates the precomputed values directly.
|
|
243
|
+
*/
|
|
244
|
+
check(
|
|
245
|
+
intent: ResolvedAccessIntent,
|
|
250
246
|
sessionRules?: Ruleset,
|
|
251
247
|
): PermissionCheckResult {
|
|
252
|
-
const { composedRules } = this.resolvePermissions(agentName);
|
|
253
|
-
const normalizedToolName = toolName.trim();
|
|
254
|
-
|
|
255
|
-
// Append session rules at the end (highest priority) so evaluate() handles
|
|
256
|
-
// them via last-match-wins — no separate per-branch pre-check needed.
|
|
248
|
+
const { composedRules } = this.resolvePermissions(intent.agentName);
|
|
257
249
|
const fullRules: Ruleset = sessionRules?.length
|
|
258
250
|
? [...composedRules, ...sessionRules]
|
|
259
251
|
: composedRules;
|
|
260
252
|
|
|
253
|
+
if (intent.kind === "path-values") {
|
|
254
|
+
const lookupValues =
|
|
255
|
+
intent.values.length > 0 ? [...intent.values] : ["*"];
|
|
256
|
+
return buildCheckResult(
|
|
257
|
+
intent.surface,
|
|
258
|
+
lookupValues,
|
|
259
|
+
{},
|
|
260
|
+
intent.surface,
|
|
261
|
+
intent.surface,
|
|
262
|
+
fullRules,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// kind === "tool"
|
|
267
|
+
const toolName = intent.surface.trim();
|
|
261
268
|
const { surface, values, resultExtras } = normalizeInput(
|
|
262
|
-
|
|
263
|
-
input,
|
|
269
|
+
toolName,
|
|
270
|
+
intent.input,
|
|
264
271
|
this.loader.getConfiguredMcpServerNames(),
|
|
265
272
|
this.currentCwd,
|
|
266
273
|
);
|
|
267
|
-
|
|
268
274
|
return buildCheckResult(
|
|
269
275
|
surface,
|
|
270
276
|
values,
|
|
271
277
|
resultExtras,
|
|
272
|
-
normalizedToolName,
|
|
273
278
|
toolName,
|
|
274
|
-
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
checkPathPolicy(
|
|
279
|
-
values: readonly string[],
|
|
280
|
-
agentName?: string,
|
|
281
|
-
sessionRules?: Ruleset,
|
|
282
|
-
surface = "path",
|
|
283
|
-
): PermissionCheckResult {
|
|
284
|
-
const { composedRules } = this.resolvePermissions(agentName);
|
|
285
|
-
const fullRules: Ruleset = sessionRules?.length
|
|
286
|
-
? [...composedRules, ...sessionRules]
|
|
287
|
-
: composedRules;
|
|
288
|
-
|
|
289
|
-
const lookupValues = values.length > 0 ? [...values] : ["*"];
|
|
290
|
-
return buildCheckResult(
|
|
291
|
-
surface,
|
|
292
|
-
lookupValues,
|
|
293
|
-
{},
|
|
294
|
-
surface,
|
|
295
|
-
surface,
|
|
279
|
+
intent.surface,
|
|
296
280
|
fullRules,
|
|
297
281
|
);
|
|
298
282
|
}
|
|
@@ -302,8 +286,8 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
302
286
|
* Evaluate a normalized surface/values triple and shape the result.
|
|
303
287
|
*
|
|
304
288
|
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent
|
|
305
|
-
* aliases); every other surface keeps {@link evaluateFirst}. Shared by
|
|
306
|
-
* `
|
|
289
|
+
* aliases); every other surface keeps {@link evaluateFirst}. Shared by the
|
|
290
|
+
* `"tool"` and `"path-values"` branches of {@link PermissionManager.check}.
|
|
307
291
|
*/
|
|
308
292
|
function buildCheckResult(
|
|
309
293
|
surface: string,
|
|
@@ -357,7 +341,7 @@ function derivePolicyLoaderOptions(
|
|
|
357
341
|
* Map a matched rule + tool name to the correct PermissionCheckResult.source.
|
|
358
342
|
*
|
|
359
343
|
* Mirrors the source-derivation logic from the former per-branch
|
|
360
|
-
*
|
|
344
|
+
* permission-check implementation:
|
|
361
345
|
*
|
|
362
346
|
* - session → "session" (always, all surfaces)
|
|
363
347
|
* - mcp + default → "default"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import { matchQualifier } from "./denial-messages";
|
|
3
2
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
4
3
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
5
4
|
import type { PermissionCheckResult } from "./types";
|
|
5
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
6
6
|
|
|
7
7
|
// NOTE: formatDenyReason, formatUserDeniedReason, and
|
|
8
8
|
// formatPermissionHardStopHint have been moved to denial-messages.ts.
|
|
@@ -1,34 +1,42 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessIntent,
|
|
3
|
+
ResolvedAccessIntent,
|
|
4
|
+
} from "./access-intent/access-intent";
|
|
1
5
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
2
6
|
import type { Rule } from "./rule";
|
|
3
7
|
import type { SessionRules } from "./session-rules";
|
|
8
|
+
import type { SkillPermissionChecker } from "./skill-prompt-sanitizer";
|
|
4
9
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
12
|
+
* Answers an {@link AccessIntent} a gate emits, applying the current session
|
|
13
|
+
* rules internally.
|
|
9
14
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* A single `resolve(intent)` entry point means adding a gate cannot widen the
|
|
16
|
+
* resolver surface, and a test fixture cannot stub one resolution method and
|
|
17
|
+
* forget another (the #393 false-green class) — #478.
|
|
13
18
|
*/
|
|
14
19
|
export interface ScopedPermissionResolver {
|
|
15
|
-
resolve(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
resolve(intent: AccessIntent): PermissionCheckResult;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reduce a gate-emitted {@link AccessIntent} to the string-based
|
|
25
|
+
* {@link ResolvedAccessIntent} the manager consumes.
|
|
26
|
+
*
|
|
27
|
+
* Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`,
|
|
28
|
+
* so the low-level manager never imports the value object.
|
|
29
|
+
*/
|
|
30
|
+
function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
|
|
31
|
+
if (intent.kind === "access-path") {
|
|
32
|
+
return {
|
|
33
|
+
kind: "path-values",
|
|
34
|
+
surface: intent.surface,
|
|
35
|
+
values: intent.path.matchValues(),
|
|
36
|
+
agentName: intent.agentName,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return intent;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/**
|
|
@@ -41,60 +49,40 @@ export interface ScopedPermissionResolver {
|
|
|
41
49
|
* - `permissionManager` — the narrow session-scoped permission-checking interface
|
|
42
50
|
* - `sessionRules` — narrowed to `getRuleset` (ISP: the resolver only reads, never records)
|
|
43
51
|
*/
|
|
44
|
-
export class PermissionResolver
|
|
52
|
+
export class PermissionResolver
|
|
53
|
+
implements ScopedPermissionResolver, SkillPermissionChecker
|
|
54
|
+
{
|
|
45
55
|
constructor(
|
|
46
56
|
private readonly permissionManager: ScopedPermissionManager,
|
|
47
57
|
private readonly sessionRules: Pick<SessionRules, "getRuleset">,
|
|
48
58
|
) {}
|
|
49
59
|
|
|
50
60
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
61
|
+
* Answer a gate-emitted access intent, composing the current session ruleset
|
|
62
|
+
* so callers never thread it by hand. Unwraps the `access-path` variant via
|
|
63
|
+
* `matchValues()` before handing a string-based intent to the manager.
|
|
54
64
|
*/
|
|
55
|
-
resolve(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
agentName?: string,
|
|
59
|
-
): PermissionCheckResult {
|
|
60
|
-
return this.checkPermission(
|
|
61
|
-
surface,
|
|
62
|
-
input,
|
|
63
|
-
agentName,
|
|
65
|
+
resolve(intent: AccessIntent): PermissionCheckResult {
|
|
66
|
+
return this.permissionManager.check(
|
|
67
|
+
toResolvedIntent(intent),
|
|
64
68
|
this.sessionRules.getRuleset(),
|
|
65
69
|
);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* aliases match against the `external_directory` rules.
|
|
73
|
+
* Raw permission check without session rules — the no-session-rules path
|
|
74
|
+
* consumed by `SkillInputGateInputs` / `SkillPermissionChecker`.
|
|
75
|
+
*
|
|
76
|
+
* Not on `ScopedPermissionResolver` (ISP: gates do not use this).
|
|
74
77
|
*/
|
|
75
|
-
resolvePathPolicy(
|
|
76
|
-
values: readonly string[],
|
|
77
|
-
agentName?: string,
|
|
78
|
-
surface = "path",
|
|
79
|
-
): PermissionCheckResult {
|
|
80
|
-
return this.permissionManager.checkPathPolicy(
|
|
81
|
-
values,
|
|
82
|
-
agentName,
|
|
83
|
-
this.sessionRules.getRuleset(),
|
|
84
|
-
surface,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
78
|
checkPermission(
|
|
89
79
|
surface: string,
|
|
90
80
|
input: unknown,
|
|
91
81
|
agentName?: string,
|
|
92
82
|
sessionRules?: Rule[],
|
|
93
83
|
): PermissionCheckResult {
|
|
94
|
-
return this.permissionManager.
|
|
95
|
-
surface,
|
|
96
|
-
input,
|
|
97
|
-
agentName,
|
|
84
|
+
return this.permissionManager.check(
|
|
85
|
+
{ kind: "tool", surface, input, agentName },
|
|
98
86
|
sessionRules,
|
|
99
87
|
);
|
|
100
88
|
}
|
|
@@ -32,10 +32,8 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
32
32
|
agentName?: string,
|
|
33
33
|
): ReturnType<PermissionsService["checkPermission"]> {
|
|
34
34
|
const input = buildInputForSurface(surface, value);
|
|
35
|
-
return this.permissionManager.
|
|
36
|
-
surface,
|
|
37
|
-
input,
|
|
38
|
-
agentName,
|
|
35
|
+
return this.permissionManager.check(
|
|
36
|
+
{ kind: "tool", surface, input, agentName },
|
|
39
37
|
this.sessionRules.getRuleset(),
|
|
40
38
|
);
|
|
41
39
|
}
|
package/src/policy-loader.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
|
|
6
4
|
import {
|
|
7
5
|
loadUnifiedConfig,
|
|
8
6
|
normalizeUnifiedConfig,
|
|
@@ -10,6 +8,8 @@ import {
|
|
|
10
8
|
} from "./config-loader";
|
|
11
9
|
import { getGlobalConfigPath } from "./config-paths";
|
|
12
10
|
import type { ScopeConfig } from "./types";
|
|
11
|
+
import { toRecord } from "./value-guards";
|
|
12
|
+
import { extractFrontmatter, parseSimpleYamlMap } from "./yaml-frontmatter";
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// File-stamp helper
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Narrow interface for the permission checker used by
|
|
11
|
-
*
|
|
10
|
+
* Narrow interface for the raw (no-session-rules) permission checker used by
|
|
11
|
+
* skill prompt resolution. `PermissionResolver` implements it (#478).
|
|
12
12
|
*/
|
|
13
13
|
export interface SkillPermissionChecker {
|
|
14
14
|
checkPermission(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import { countTextLines, formatCount } from "./tool-input-preview";
|
|
2
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
3
3
|
|
|
4
4
|
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
5
5
|
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
2
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
4
3
|
import {
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
getPromptPath,
|
|
16
15
|
} from "./tool-input-prompt-formatters";
|
|
17
16
|
import type { PermissionCheckResult } from "./types";
|
|
17
|
+
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
18
18
|
|
|
19
19
|
export interface ToolPreviewFormatterOptions {
|
|
20
20
|
toolInputPreviewMaxLength: number;
|
package/src/tool-registry.ts
CHANGED
|
@@ -53,69 +53,3 @@ export function isDenyWithReason(value: unknown): value is DenyWithReason {
|
|
|
53
53
|
(record.reason === undefined || typeof record.reason === "string")
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
58
|
-
|
|
59
|
-
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
60
|
-
const root: Record<string, unknown> = {};
|
|
61
|
-
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
62
|
-
|
|
63
|
-
const lines = input.split(/\r?\n/);
|
|
64
|
-
for (const rawLine of lines) {
|
|
65
|
-
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const indent = rawLine.length - rawLine.trimStart().length;
|
|
70
|
-
const line = rawLine.trim();
|
|
71
|
-
const separatorIndex = line.indexOf(":");
|
|
72
|
-
if (separatorIndex <= 0) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const key = line
|
|
77
|
-
.slice(0, separatorIndex)
|
|
78
|
-
.trim()
|
|
79
|
-
.replace(/^['"]|['"]$/g, "");
|
|
80
|
-
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
81
|
-
|
|
82
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
83
|
-
stack.pop();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const current = stack[stack.length - 1].target;
|
|
87
|
-
|
|
88
|
-
if (!rawValue) {
|
|
89
|
-
const child: Record<string, unknown> = {};
|
|
90
|
-
current[key] = child;
|
|
91
|
-
stack.push({ indent, target: child });
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
let scalar = rawValue;
|
|
96
|
-
if (
|
|
97
|
-
(scalar.startsWith('"') && scalar.endsWith('"')) ||
|
|
98
|
-
(scalar.startsWith("'") && scalar.endsWith("'"))
|
|
99
|
-
) {
|
|
100
|
-
scalar = scalar.slice(1, -1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
current[key] = scalar;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return root;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function extractFrontmatter(markdown: string): string {
|
|
110
|
-
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
111
|
-
if (!normalized.startsWith("---\n")) {
|
|
112
|
-
return "";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const end = normalized.indexOf("\n---", 4);
|
|
116
|
-
if (end === -1) {
|
|
117
|
-
return "";
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return normalized.slice(4, end);
|
|
121
|
-
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
type StackNode = { indent: number; target: Record<string, unknown> };
|
|
2
|
+
|
|
3
|
+
export function parseSimpleYamlMap(input: string): Record<string, unknown> {
|
|
4
|
+
const root: Record<string, unknown> = {};
|
|
5
|
+
const stack: StackNode[] = [{ indent: -1, target: root }];
|
|
6
|
+
|
|
7
|
+
const lines = input.split(/\r?\n/);
|
|
8
|
+
for (const rawLine of lines) {
|
|
9
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
14
|
+
const line = rawLine.trim();
|
|
15
|
+
const separatorIndex = line.indexOf(":");
|
|
16
|
+
if (separatorIndex <= 0) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const key = line
|
|
21
|
+
.slice(0, separatorIndex)
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/^['"]|['"]$/g, "");
|
|
24
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
25
|
+
|
|
26
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
27
|
+
stack.pop();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const current = stack[stack.length - 1].target;
|
|
31
|
+
|
|
32
|
+
if (!rawValue) {
|
|
33
|
+
const child: Record<string, unknown> = {};
|
|
34
|
+
current[key] = child;
|
|
35
|
+
stack.push({ indent, target: child });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let scalar = rawValue;
|
|
40
|
+
if (
|
|
41
|
+
(scalar.startsWith('"') && scalar.endsWith('"')) ||
|
|
42
|
+
(scalar.startsWith("'") && scalar.endsWith("'"))
|
|
43
|
+
) {
|
|
44
|
+
scalar = scalar.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
current[key] = scalar;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return root;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractFrontmatter(markdown: string): string {
|
|
54
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
55
|
+
if (!normalized.startsWith("---\n")) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const end = normalized.indexOf("\n---", 4);
|
|
60
|
+
if (end === -1) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return normalized.slice(4, end);
|
|
65
|
+
}
|
|
@@ -435,6 +435,73 @@ describe("BashProgram", () => {
|
|
|
435
435
|
expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
|
|
436
436
|
expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
|
|
437
437
|
});
|
|
438
|
+
|
|
439
|
+
it("strips a leading env-var assignment prefix", async () => {
|
|
440
|
+
const program = await BashProgram.parse(
|
|
441
|
+
"AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
|
|
442
|
+
cwd,
|
|
443
|
+
);
|
|
444
|
+
expect(program.commands()).toEqual([
|
|
445
|
+
{ text: "aws ec2 terminate-instances --instance-ids i-1" },
|
|
446
|
+
]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("strips multiple leading env-var assignments", async () => {
|
|
450
|
+
const program = await BashProgram.parse("A=1 B=2 aws s3 ls", cwd);
|
|
451
|
+
expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("strips the env-var prefix of each command in a chain", async () => {
|
|
455
|
+
const program = await BashProgram.parse(
|
|
456
|
+
"X=1 aws sts get-caller-identity && ls",
|
|
457
|
+
cwd,
|
|
458
|
+
);
|
|
459
|
+
expect(program.commands()).toEqual([
|
|
460
|
+
{ text: "aws sts get-caller-identity" },
|
|
461
|
+
{ text: "ls" },
|
|
462
|
+
]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("keeps a pure assignment with no command unchanged", async () => {
|
|
466
|
+
const program = await BashProgram.parse("FOO=bar", cwd);
|
|
467
|
+
expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("opaque-payload wrappers", () => {
|
|
471
|
+
it.each([
|
|
472
|
+
['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
|
|
473
|
+
['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
|
|
474
|
+
['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
|
|
475
|
+
['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
|
|
476
|
+
['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
|
|
477
|
+
['eval "rm -rf /"', 'eval "rm -rf /"'],
|
|
478
|
+
['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
|
|
479
|
+
['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
|
|
480
|
+
])("flags %s as opaque", async (command, text) => {
|
|
481
|
+
const program = await BashProgram.parse(command, cwd);
|
|
482
|
+
expect(program.commands()).toEqual([{ text, opaque: true }]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
|
|
486
|
+
const program = await BashProgram.parse(
|
|
487
|
+
'AWS_PROFILE=prod bash -c "rm -rf /"',
|
|
488
|
+
cwd,
|
|
489
|
+
);
|
|
490
|
+
expect(program.commands()).toEqual([
|
|
491
|
+
{ text: 'bash -c "rm -rf /"', opaque: true },
|
|
492
|
+
]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it.each([
|
|
496
|
+
"bash script.sh",
|
|
497
|
+
"bash",
|
|
498
|
+
"ls -la",
|
|
499
|
+
"grep -c foo file",
|
|
500
|
+
])("does not flag %s as opaque", async (command) => {
|
|
501
|
+
const program = await BashProgram.parse(command, cwd);
|
|
502
|
+
expect(program.commands()).toEqual([{ text: command }]);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
438
505
|
});
|
|
439
506
|
|
|
440
507
|
it("derives both slices from a single parse", async () => {
|
|
@@ -49,10 +49,8 @@ function makeSetup(opts?: {
|
|
|
49
49
|
opts.toolPermission,
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
|
-
// Default
|
|
53
|
-
vi.mocked(permissionManager.
|
|
54
|
-
makeCheckResult(),
|
|
55
|
-
);
|
|
52
|
+
// Default check returns allow (for skill-prompt sanitizer via resolver.checkPermission)
|
|
53
|
+
vi.mocked(permissionManager.check).mockReturnValue(makeCheckResult());
|
|
56
54
|
const toolRegistry = makeToolRegistry(opts?.toolRegistry);
|
|
57
55
|
const handler = new AgentPrepHandler(session, resolver, toolRegistry);
|
|
58
56
|
return {
|
|
@@ -195,11 +193,10 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
195
193
|
"</available_skills>",
|
|
196
194
|
].join("\n");
|
|
197
195
|
const { handler, permissionManager } = makeSetup();
|
|
198
|
-
vi.mocked(permissionManager.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
: makeCheckResult(),
|
|
196
|
+
vi.mocked(permissionManager.check).mockImplementation((intent) =>
|
|
197
|
+
intent.surface === "skill"
|
|
198
|
+
? makeCheckResult({ state: "deny" })
|
|
199
|
+
: makeCheckResult(),
|
|
203
200
|
);
|
|
204
201
|
|
|
205
202
|
const first = await handler.handle(makeEvent(systemPrompt), makeCtx());
|