@gotgenes/pi-permission-system 7.4.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-path-extractor.ts +91 -4
- package/src/index.ts +0 -6
- package/src/service.ts +1 -23
- package/src/subagent-registry.ts +3 -3
- package/test/bash-external-directory.test.ts +76 -0
- package/test/service.test.ts +0 -54
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [8.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.4.1...pi-permission-system-v8.0.0) (2026-05-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* `registerSubagentSession` and `unregisterSubagentSession` are removed from the `PermissionsService` interface and its implementation. The `SubagentSessionInfo` type is no longer re-exported from the public service module.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* remove inbound subagent-registration methods from PermissionsService ([#267](https://github.com/gotgenes/pi-packages/issues/267)) ([552735a](https://github.com/gotgenes/pi-packages/commit/552735a97eec939fc06130bce059c78f03eb8e58))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** describe event-driven subagent registration ([#267](https://github.com/gotgenes/pi-packages/issues/267)) ([8c39b87](https://github.com/gotgenes/pi-packages/commit/8c39b8785aa389d96b5f38996711d8aa3dbeb284))
|
|
23
|
+
|
|
24
|
+
## [7.4.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.4.0...pi-permission-system-v7.4.1) (2026-05-30)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** resolve bash paths against leading cd target ([c655a7e](https://github.com/gotgenes/pi-packages/commit/c655a7e737aeac9a8f10909804260c65d339c8b7))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** document cd-aware bash path resolution ([a2e6541](https://github.com/gotgenes/pi-packages/commit/a2e65410e89eb1e62579078c16af05aead013603))
|
|
35
|
+
|
|
8
36
|
## [7.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.3...pi-permission-system-v7.4.0) (2026-05-29)
|
|
9
37
|
|
|
10
38
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { basename } from "node:path";
|
|
2
|
+
import { basename, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
isPathWithinDirectory,
|
|
6
|
+
isSafeSystemPath,
|
|
6
7
|
normalizePathForComparison,
|
|
7
8
|
} from "#src/path-utils";
|
|
8
9
|
|
|
@@ -501,11 +502,90 @@ function classifyTokenAsPathCandidate(token: string): string | null {
|
|
|
501
502
|
return null;
|
|
502
503
|
}
|
|
503
504
|
|
|
505
|
+
// ── Leading cd detection ───────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Walk down from the root to find the first `command` node in the program.
|
|
509
|
+
*
|
|
510
|
+
* Only descends into `program` and `list` nodes — subshells, pipelines, and
|
|
511
|
+
* other compound statements are ignored because a `cd` inside them does not
|
|
512
|
+
* affect the outer shell's working directory.
|
|
513
|
+
*/
|
|
514
|
+
function findFirstCommand(node: TSNode): TSNode | null {
|
|
515
|
+
if (node.type === "command") return node;
|
|
516
|
+
if (node.type === "program" || node.type === "list") {
|
|
517
|
+
const firstChild = node.child(0);
|
|
518
|
+
if (firstChild) return findFirstCommand(firstChild);
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Extract the target directory of a leading `cd` command from the parsed AST.
|
|
525
|
+
*
|
|
526
|
+
* When a bash command begins with `cd <dir> && …`, the shell resolves
|
|
527
|
+
* subsequent relative paths against `<dir>`, not the original working
|
|
528
|
+
* directory. The external-directory guard must do the same, otherwise a
|
|
529
|
+
* path that the shell keeps inside the working directory can appear to
|
|
530
|
+
* escape it and trigger a spurious permission prompt.
|
|
531
|
+
*
|
|
532
|
+
* Returns `undefined` when the first command is not `cd`, or when the
|
|
533
|
+
* target cannot be meaningfully resolved (`cd -`, bare `cd`, or `cd ~…`).
|
|
534
|
+
*/
|
|
535
|
+
function extractLeadingCdTarget(rootNode: TSNode): string | undefined {
|
|
536
|
+
const firstCmd = findFirstCommand(rootNode);
|
|
537
|
+
if (!firstCmd) return undefined;
|
|
538
|
+
|
|
539
|
+
const cmdName = extractCommandName(firstCmd);
|
|
540
|
+
if (cmdName !== "cd") return undefined;
|
|
541
|
+
|
|
542
|
+
for (let i = 0; i < firstCmd.childCount; i++) {
|
|
543
|
+
const child = firstCmd.child(i);
|
|
544
|
+
if (!child) continue;
|
|
545
|
+
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
546
|
+
continue;
|
|
547
|
+
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
548
|
+
|
|
549
|
+
const text = resolveNodeText(child);
|
|
550
|
+
// Skip `--` (end-of-flags marker)
|
|
551
|
+
if (text === "--") continue;
|
|
552
|
+
// `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
|
|
553
|
+
// resolved against the working directory.
|
|
554
|
+
if (text === "-" || text.startsWith("~")) return undefined;
|
|
555
|
+
return text;
|
|
556
|
+
}
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Compute the effective base directory for resolving relative path candidates.
|
|
562
|
+
*
|
|
563
|
+
* When the leading `cd` target stays within the working directory, subsequent
|
|
564
|
+
* relative paths should be resolved against it. An escaping target is itself
|
|
565
|
+
* an external access (reported via its own candidate token) and must never
|
|
566
|
+
* silence checks on subsequent paths, so the function falls back to `cwd`.
|
|
567
|
+
*/
|
|
568
|
+
function computeEffectiveResolveBase(
|
|
569
|
+
cdTarget: string | undefined,
|
|
570
|
+
cwd: string,
|
|
571
|
+
): string {
|
|
572
|
+
if (cdTarget === undefined) return cwd;
|
|
573
|
+
const resolved = resolve(cwd, cdTarget);
|
|
574
|
+
const normalizedCwd = resolve(cwd);
|
|
575
|
+
return isPathWithinDirectory(resolved, normalizedCwd) ? resolved : cwd;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── Public extractors ──────────────────────────────────────────────────────
|
|
579
|
+
|
|
504
580
|
/**
|
|
505
581
|
* Extracts paths from a bash command string that resolve outside CWD.
|
|
506
582
|
* Uses tree-sitter-bash to parse the command into a full AST, then walks
|
|
507
583
|
* command argument and redirect-destination nodes. Heredoc bodies, comments,
|
|
508
584
|
* and other non-argument content are skipped, eliminating false positives.
|
|
585
|
+
*
|
|
586
|
+
* When the command begins with `cd <dir> && …`, relative candidate paths are
|
|
587
|
+
* resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
|
|
588
|
+
* mirroring how the shell would resolve them.
|
|
509
589
|
*/
|
|
510
590
|
export async function extractExternalPathsFromBashCommand(
|
|
511
591
|
command: string,
|
|
@@ -515,13 +595,18 @@ export async function extractExternalPathsFromBashCommand(
|
|
|
515
595
|
const tree = parser.parse(command);
|
|
516
596
|
if (!tree) return [];
|
|
517
597
|
|
|
598
|
+
let cdTarget: string | undefined;
|
|
518
599
|
const tokens: string[] = [];
|
|
519
600
|
try {
|
|
601
|
+
cdTarget = extractLeadingCdTarget(tree.rootNode);
|
|
520
602
|
collectPathCandidateTokens(tree.rootNode, tokens);
|
|
521
603
|
} finally {
|
|
522
604
|
tree.delete();
|
|
523
605
|
}
|
|
524
606
|
|
|
607
|
+
const resolveBase = computeEffectiveResolveBase(cdTarget, cwd);
|
|
608
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
609
|
+
|
|
525
610
|
const seen = new Set<string>();
|
|
526
611
|
const externalPaths: string[] = [];
|
|
527
612
|
|
|
@@ -529,11 +614,13 @@ export async function extractExternalPathsFromBashCommand(
|
|
|
529
614
|
const candidate = classifyTokenAsPathCandidate(token);
|
|
530
615
|
if (!candidate) continue;
|
|
531
616
|
|
|
532
|
-
const normalized = normalizePathForComparison(candidate,
|
|
617
|
+
const normalized = normalizePathForComparison(candidate, resolveBase);
|
|
533
618
|
if (!normalized) continue;
|
|
534
619
|
|
|
535
620
|
if (
|
|
536
|
-
|
|
621
|
+
normalizedCwd !== "" &&
|
|
622
|
+
!isSafeSystemPath(normalized) &&
|
|
623
|
+
!isPathWithinDirectory(normalized, normalizedCwd) &&
|
|
537
624
|
!seen.has(normalized)
|
|
538
625
|
) {
|
|
539
626
|
seen.add(normalized);
|
package/src/index.ts
CHANGED
|
@@ -118,12 +118,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
118
118
|
sessionRules,
|
|
119
119
|
);
|
|
120
120
|
},
|
|
121
|
-
registerSubagentSession(sessionKey, info) {
|
|
122
|
-
subagentRegistry.register(sessionKey, info);
|
|
123
|
-
},
|
|
124
|
-
unregisterSubagentSession(sessionKey) {
|
|
125
|
-
subagentRegistry.unregister(sessionKey);
|
|
126
|
-
},
|
|
127
121
|
getToolPermission(toolName, agentName) {
|
|
128
122
|
return runtime.permissionManager.getToolPermission(toolName, agentName);
|
|
129
123
|
},
|
package/src/service.ts
CHANGED
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { SubagentSessionInfo } from "./subagent-registry";
|
|
15
14
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
16
15
|
|
|
17
|
-
export type { PermissionCheckResult, PermissionState
|
|
16
|
+
export type { PermissionCheckResult, PermissionState };
|
|
18
17
|
|
|
19
18
|
/** Process-global key for the service slot. */
|
|
20
19
|
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
@@ -44,27 +43,6 @@ export interface PermissionsService {
|
|
|
44
43
|
agentName?: string,
|
|
45
44
|
): PermissionCheckResult;
|
|
46
45
|
|
|
47
|
-
/**
|
|
48
|
-
* Register an in-process subagent session.
|
|
49
|
-
*
|
|
50
|
-
* Call this before `bindExtensions()` so that `isSubagentExecutionContext()`
|
|
51
|
-
* and permission-forwarding target resolution can detect the child session.
|
|
52
|
-
* Always pair with `unregisterSubagentSession()` in a `finally` block.
|
|
53
|
-
*
|
|
54
|
-
* @param sessionKey - Unique session identifier (use the session directory path).
|
|
55
|
-
* @param info - Agent name and optional parent session ID.
|
|
56
|
-
*/
|
|
57
|
-
registerSubagentSession(sessionKey: string, info: SubagentSessionInfo): void;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Remove a previously registered in-process subagent session.
|
|
61
|
-
*
|
|
62
|
-
* Safe to call even if `registerSubagentSession` was never called for this key.
|
|
63
|
-
*
|
|
64
|
-
* @param sessionKey - The same key passed to `registerSubagentSession`.
|
|
65
|
-
*/
|
|
66
|
-
unregisterSubagentSession(sessionKey: string): void;
|
|
67
|
-
|
|
68
46
|
/**
|
|
69
47
|
* Query the tool-level permission state for pre-filtering tools before
|
|
70
48
|
* creating a child session.
|
package/src/subagent-registry.ts
CHANGED
|
@@ -23,9 +23,9 @@ export interface SubagentSessionInfo {
|
|
|
23
23
|
/**
|
|
24
24
|
* Registry of active in-process subagent sessions.
|
|
25
25
|
*
|
|
26
|
-
* Owned by `ExtensionRuntime`;
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
26
|
+
* Owned by `ExtensionRuntime`; written exclusively by `subscribeSubagentLifecycle`
|
|
27
|
+
* via the `subagents:child:session-created` / `subagents:child:disposed` event
|
|
28
|
+
* subscription (ADR 0002 — the core publishes, consumers observe).
|
|
29
29
|
*
|
|
30
30
|
* Concurrent background agents are safe because each session has a unique
|
|
31
31
|
* directory path as its key — no scalar global flag is needed.
|
|
@@ -822,6 +822,82 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
822
822
|
expect(result).toContain("/etc/hosts");
|
|
823
823
|
});
|
|
824
824
|
});
|
|
825
|
+
|
|
826
|
+
describe("leading cd prefix", () => {
|
|
827
|
+
test("regression: cd to subdir with relative path traversing back into cwd is not flagged", async () => {
|
|
828
|
+
// Real-world command that triggered a false-positive external-directory
|
|
829
|
+
// prompt. The relative path .pi/../../../.pi/skills/... resolves inside
|
|
830
|
+
// cwd when resolved from the cd target, but outside cwd when resolved
|
|
831
|
+
// from cwd itself.
|
|
832
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
833
|
+
'cd /projects/my-app/packages/sub && grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
|
|
834
|
+
cwd,
|
|
835
|
+
);
|
|
836
|
+
expect(result).toHaveLength(0);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("cd to subdir: still flags genuinely external paths after cd", async () => {
|
|
840
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
841
|
+
"cd /projects/my-app/packages/sub && cat /etc/hosts",
|
|
842
|
+
cwd,
|
|
843
|
+
);
|
|
844
|
+
expect(result).toContain("/etc/hosts");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("cd to subdir: relative path that stays inside cwd is not flagged", async () => {
|
|
848
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
849
|
+
"cd /projects/my-app/src && cat ../README.md",
|
|
850
|
+
cwd,
|
|
851
|
+
);
|
|
852
|
+
expect(result).toHaveLength(0);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("cd to external dir: paths after cd are still checked against cwd", async () => {
|
|
856
|
+
// When cd target is outside cwd, we fall back to cwd as the resolve base.
|
|
857
|
+
// The cd target itself should be flagged, and paths after cd are resolved
|
|
858
|
+
// against cwd.
|
|
859
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
860
|
+
"cd /tmp && cat ../etc/hosts",
|
|
861
|
+
cwd,
|
|
862
|
+
);
|
|
863
|
+
expect(result.length).toBeGreaterThan(0);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("cd with relative target: resolves inside cwd", async () => {
|
|
867
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
868
|
+
'cd packages/sub && grep -n "x" .pi/../../../.pi/skills/pkg/SKILL.md',
|
|
869
|
+
cwd,
|
|
870
|
+
);
|
|
871
|
+
expect(result).toHaveLength(0);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("no cd prefix: ../ path that escapes cwd is flagged", async () => {
|
|
875
|
+
// Without the cd prefix, the path resolves against cwd and escapes.
|
|
876
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
877
|
+
'grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
|
|
878
|
+
cwd,
|
|
879
|
+
);
|
|
880
|
+
expect(result.length).toBeGreaterThan(0);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("cd is not first command: cd is ignored", async () => {
|
|
884
|
+
// cd after another command should not affect path resolution.
|
|
885
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
886
|
+
"echo hello && cd /projects/my-app/src && cat ../../outside.txt",
|
|
887
|
+
cwd,
|
|
888
|
+
);
|
|
889
|
+
// ../../outside.txt resolves against cwd, not the cd target
|
|
890
|
+
expect(result.length).toBeGreaterThan(0);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("cd with semicolon separator", async () => {
|
|
894
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
895
|
+
"cd /projects/my-app/src ; cat ../README.md",
|
|
896
|
+
cwd,
|
|
897
|
+
);
|
|
898
|
+
expect(result).toHaveLength(0);
|
|
899
|
+
});
|
|
900
|
+
});
|
|
825
901
|
});
|
|
826
902
|
|
|
827
903
|
describe("formatBashExternalDirectoryAskPrompt", () => {
|
package/test/service.test.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
publishPermissionsService,
|
|
7
7
|
unpublishPermissionsService,
|
|
8
8
|
} from "#src/service";
|
|
9
|
-
import { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
10
9
|
import type { PermissionCheckResult } from "#src/types";
|
|
11
10
|
|
|
12
11
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -16,8 +15,6 @@ function makeService(
|
|
|
16
15
|
): PermissionsService {
|
|
17
16
|
return {
|
|
18
17
|
checkPermission: vi.fn(),
|
|
19
|
-
registerSubagentSession: vi.fn(),
|
|
20
|
-
unregisterSubagentSession: vi.fn(),
|
|
21
18
|
getToolPermission: vi.fn(),
|
|
22
19
|
...overrides,
|
|
23
20
|
};
|
|
@@ -130,61 +127,12 @@ describe("service adapter delegation", () => {
|
|
|
130
127
|
);
|
|
131
128
|
});
|
|
132
129
|
|
|
133
|
-
it("registerSubagentSession delegates to the registry", () => {
|
|
134
|
-
const registry = new SubagentSessionRegistry();
|
|
135
|
-
const service: PermissionsService = {
|
|
136
|
-
checkPermission: vi.fn(),
|
|
137
|
-
registerSubagentSession(key, info) {
|
|
138
|
-
registry.register(key, info);
|
|
139
|
-
},
|
|
140
|
-
unregisterSubagentSession(key) {
|
|
141
|
-
registry.unregister(key);
|
|
142
|
-
},
|
|
143
|
-
getToolPermission: vi.fn((): "allow" => "allow"),
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
publishPermissionsService(service);
|
|
147
|
-
getPermissionsService()!.registerSubagentSession("/sessions/task-1", {
|
|
148
|
-
agentName: "Explore",
|
|
149
|
-
parentSessionId: "parent-abc",
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
expect(registry.has("/sessions/task-1")).toBe(true);
|
|
153
|
-
expect(registry.get("/sessions/task-1")).toEqual({
|
|
154
|
-
agentName: "Explore",
|
|
155
|
-
parentSessionId: "parent-abc",
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("unregisterSubagentSession delegates to the registry", () => {
|
|
160
|
-
const registry = new SubagentSessionRegistry();
|
|
161
|
-
const service: PermissionsService = {
|
|
162
|
-
checkPermission: vi.fn(),
|
|
163
|
-
registerSubagentSession(key, info) {
|
|
164
|
-
registry.register(key, info);
|
|
165
|
-
},
|
|
166
|
-
unregisterSubagentSession(key) {
|
|
167
|
-
registry.unregister(key);
|
|
168
|
-
},
|
|
169
|
-
getToolPermission: vi.fn((): "allow" => "allow"),
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
publishPermissionsService(service);
|
|
173
|
-
const svc = getPermissionsService()!;
|
|
174
|
-
svc.registerSubagentSession("/sessions/task-1", { agentName: "Explore" });
|
|
175
|
-
svc.unregisterSubagentSession("/sessions/task-1");
|
|
176
|
-
|
|
177
|
-
expect(registry.has("/sessions/task-1")).toBe(false);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
130
|
it("getToolPermission delegates to the permission manager", () => {
|
|
181
131
|
const getToolPermissionFn = vi.fn(
|
|
182
132
|
(_t: string, _a?: string): "deny" => "deny",
|
|
183
133
|
);
|
|
184
134
|
const service: PermissionsService = {
|
|
185
135
|
checkPermission: vi.fn(),
|
|
186
|
-
registerSubagentSession: vi.fn(),
|
|
187
|
-
unregisterSubagentSession: vi.fn(),
|
|
188
136
|
getToolPermission(toolName, agentName) {
|
|
189
137
|
return getToolPermissionFn(toolName, agentName);
|
|
190
138
|
},
|
|
@@ -206,8 +154,6 @@ describe("service adapter delegation", () => {
|
|
|
206
154
|
);
|
|
207
155
|
const service: PermissionsService = {
|
|
208
156
|
checkPermission: vi.fn(),
|
|
209
|
-
registerSubagentSession: vi.fn(),
|
|
210
|
-
unregisterSubagentSession: vi.fn(),
|
|
211
157
|
getToolPermission(toolName, agentName) {
|
|
212
158
|
return getToolPermissionFn(toolName, agentName);
|
|
213
159
|
},
|