agent-control-plane-core 0.1.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/README.md +194 -0
- package/bin/amp-hook.mjs +21 -0
- package/bin/claude-hook.mjs +20 -0
- package/bin/codex-hook.mjs +20 -0
- package/bin/gemini-hook.mjs +19 -0
- package/bin/hook-runtime.mjs +69 -0
- package/package.json +109 -0
- package/src/adapters/amp.mjs +82 -0
- package/src/adapters/claude.mjs +227 -0
- package/src/adapters/codex.mjs +149 -0
- package/src/adapters/gemini.mjs +175 -0
- package/src/conformance.mjs +121 -0
- package/src/control-plane.mjs +299 -0
- package/src/fixtures/amp.json +121 -0
- package/src/fixtures/claude.json +328 -0
- package/src/fixtures/codex.json +222 -0
- package/src/fixtures/gemini.json +187 -0
- package/src/index.mjs +17 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendor-neutral control-plane contract.
|
|
3
|
+
*
|
|
4
|
+
* This is the published seam between an agent's native hook/tool-call protocol
|
|
5
|
+
* and claude-guard's guardrails (monitor, deny-match, redaction, sanitizers).
|
|
6
|
+
* It defines TWO normalized shapes — {@link ToolCallEvent} (what an agent is
|
|
7
|
+
* about to / just did) and {@link Verdict} (what the guardrail decided) — plus
|
|
8
|
+
* the {@link Adapter} pair that translates a specific agent's protocol to and
|
|
9
|
+
* from them. Every downstream consumer imports THESE types, never an agent's
|
|
10
|
+
* raw hook JSON; the agent-specific field names live only in that agent's
|
|
11
|
+
* adapter (see adapter-claude.mjs / adapter-codex.mjs).
|
|
12
|
+
*
|
|
13
|
+
* DRIFT DISCIPLINE — the reason the seam earns its keep. An agent protocol
|
|
14
|
+
* drifts additively and independently (N agents, N release cadences). So an
|
|
15
|
+
* adapter's `parse` MUST NOT throw on an event type or tool-input field it does
|
|
16
|
+
* not model: the unmodelled remainder is carried through verbatim (an unknown
|
|
17
|
+
* event becomes {@link EventKind.UNKNOWN} with its native name preserved; extra
|
|
18
|
+
* top-level fields land in `meta.passthrough`). An additive upstream change is
|
|
19
|
+
* then a no-op here, not an outage. The core models ONLY the stable middle:
|
|
20
|
+
* four event kinds, three decisions, and the Bash/Edit/Write/Read/WebFetch tool
|
|
21
|
+
* inputs. Exotic per-agent tools pass through untouched.
|
|
22
|
+
*
|
|
23
|
+
* Dependency-free on purpose, so a fail-closed hook can import it without
|
|
24
|
+
* dragging in eager config-file reads.
|
|
25
|
+
*
|
|
26
|
+
* VERSIONING — this module IS the frozen contract (its own SSOT, no parallel
|
|
27
|
+
* schema file to drift). Adapters and guardrail consumers are built against it
|
|
28
|
+
* in parallel, so its shapes are stable: {@link EventKind}, {@link Decision},
|
|
29
|
+
* and {@link MODELED_TOOLS} are frozen, and {@link SCHEMA_VERSION} /
|
|
30
|
+
* {@link CONTROL_PLANE_SCHEMA} are pinned (control-plane.test.mjs asserts the
|
|
31
|
+
* exact values, so any shape change is a deliberate, reviewed version bump).
|
|
32
|
+
* ADDING to the contract (a new optional field, a new modeled tool) is
|
|
33
|
+
* backward-compatible and stays at v1; RENAMING or REMOVING a field, or
|
|
34
|
+
* changing a decision/event vocabulary, is breaking and bumps the version.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Wire identifier for this schema version; bump on a breaking shape change. */
|
|
38
|
+
export const CONTROL_PLANE_SCHEMA = "control-plane/v1";
|
|
39
|
+
|
|
40
|
+
/** Numeric schema version stamped onto every {@link ToolCallEvent}. */
|
|
41
|
+
export const SCHEMA_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The normalized event kinds the core models. A native event that maps to none
|
|
45
|
+
* of these is carried as {@link EventKind.UNKNOWN} with its native name kept in
|
|
46
|
+
* `meta.native_event`.
|
|
47
|
+
*/
|
|
48
|
+
export const EventKind = Object.freeze({
|
|
49
|
+
PRE_TOOL: "pre_tool",
|
|
50
|
+
POST_TOOL: "post_tool",
|
|
51
|
+
PROMPT_SUBMIT: "prompt_submit",
|
|
52
|
+
SESSION_START: "session_start",
|
|
53
|
+
UNKNOWN: "unknown",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** The normalized verdict decisions a guardrail can return. */
|
|
57
|
+
export const Decision = Object.freeze({
|
|
58
|
+
ALLOW: "allow",
|
|
59
|
+
DENY: "deny",
|
|
60
|
+
ASK: "ask",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tools whose input shape the core models. Every other tool passes through
|
|
65
|
+
* unmodelled — its input object is preserved verbatim and no field is required.
|
|
66
|
+
*/
|
|
67
|
+
export const MODELED_TOOLS = Object.freeze([
|
|
68
|
+
"Bash",
|
|
69
|
+
"Edit",
|
|
70
|
+
"Write",
|
|
71
|
+
"Read",
|
|
72
|
+
"WebFetch",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How a guardrail ATTACHES to an agent — the transport, kept separate from the
|
|
77
|
+
* normalized decision so the judge/Verdict core stays transport-agnostic:
|
|
78
|
+
* - EXTERNAL_HOOK: the agent shells out to a hook (stdin JSON → deny body /
|
|
79
|
+
* exit code). The decision can pre-empt the tool call.
|
|
80
|
+
* - IN_PROCESS: an embedded analyzer or a driven confirm/reject loop over the
|
|
81
|
+
* agent's own API. Can pre-empt.
|
|
82
|
+
* - OBSERVE_ONLY: transcript reader; cannot pre-empt, so its Verdict is
|
|
83
|
+
* advisory — the hard stop is the sandbox, not the hook.
|
|
84
|
+
*/
|
|
85
|
+
export const IntegrationMode = Object.freeze({
|
|
86
|
+
EXTERNAL_HOOK: "external_hook",
|
|
87
|
+
IN_PROCESS: "in_process",
|
|
88
|
+
OBSERVE_ONLY: "observe_only",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {object} EventMeta
|
|
93
|
+
* @property {string} agent producing agent id ("claude", "codex", …)
|
|
94
|
+
* @property {string} native_event original native event name, preserved verbatim
|
|
95
|
+
* @property {"external_hook"|"in_process"|"observe_only"} integration_mode how the guardrail attaches
|
|
96
|
+
* @property {boolean} primary_gate_present the agent's own native gate already ran (⇒ the monitor is a SECOND opinion; the LLM call can be skipped when the native gate already blocked)
|
|
97
|
+
* @property {string} [session_id]
|
|
98
|
+
* @property {string} [cwd]
|
|
99
|
+
* @property {string} [permission_mode]
|
|
100
|
+
* @property {string} [transcript_path]
|
|
101
|
+
* @property {Record<string, unknown>} passthrough unmodelled native top-level fields, verbatim
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The result of rendering a {@link Verdict} for a specific agent's transport —
|
|
106
|
+
* the ATTACH mechanism, not the decision. A caller applies whichever channels
|
|
107
|
+
* are present: write `stdout`, exit with `exit_code`, or `throw`.
|
|
108
|
+
* @typedef {object} NativeResponse
|
|
109
|
+
* @property {"external_hook"|"in_process"|"observe_only"} transport
|
|
110
|
+
* @property {number} exit_code process exit code carrying the decision (0 = proceed)
|
|
111
|
+
* @property {boolean} enforced whether THIS render actually blocks (false ⇒ advisory only)
|
|
112
|
+
* @property {unknown} [stdout] native JSON body to write to stdout, when the transport uses one
|
|
113
|
+
* @property {{ message: string }} [throw_] a thrown-error block signal (plugin transports, e.g. opencode)
|
|
114
|
+
* @property {ConfigFallback} [fallback] an out-of-band enforcement the adapter ALSO requires (external pin / config deny-wildcards)
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* An enforcement the adapter cannot express in-band and so hands to the sandbox
|
|
119
|
+
* to apply out-of-band — because the in-agent hook has a documented hole:
|
|
120
|
+
* - `external_pin`: the agent can re-enable/override its own hook, so the
|
|
121
|
+
* sandbox must pin it from OUTSIDE (read-only config mount / PATH-shadow).
|
|
122
|
+
* - `config_deny`: an in-agent throw doesn't reach every path (e.g. opencode
|
|
123
|
+
* MCP tools / subagents), so a config-level deny-by-name wildcard is ALSO
|
|
124
|
+
* required. `uncovered` names paths still not covered (a logged, known gap).
|
|
125
|
+
* @typedef {object} ConfigFallback
|
|
126
|
+
* @property {"external_pin"|"config_deny"} kind
|
|
127
|
+
* @property {string} reason
|
|
128
|
+
* @property {string[]} [deny_globs] tool-NAME globs to deny at config level (config_deny)
|
|
129
|
+
* @property {string[]} [uncovered] paths this fallback still cannot reach (known gap)
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A normalized, agent-agnostic view of one agent event.
|
|
134
|
+
* @typedef {object} ToolCallEvent
|
|
135
|
+
* @property {number} schema_version stamped {@link SCHEMA_VERSION}
|
|
136
|
+
* @property {"pre_tool"|"post_tool"|"prompt_submit"|"session_start"|"unknown"} event
|
|
137
|
+
* @property {string|null} tool tool name (null for prompt/session events)
|
|
138
|
+
* @property {Record<string, unknown>} input passthrough tool input; a submitted prompt is folded into `input.prompt`
|
|
139
|
+
* @property {unknown} [response] tool output, post_tool only (string or structured), verbatim
|
|
140
|
+
* @property {boolean} this_call_vetoable false ⇒ the guardrail cannot veto THIS call; a monitor must auto-degrade deny to notify, and any render of it stays advisory (never `enforced`)
|
|
141
|
+
* @property {EventMeta} meta
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A normalized guardrail decision.
|
|
146
|
+
* @typedef {object} Verdict
|
|
147
|
+
* @property {"allow"|"deny"|"ask"} decision
|
|
148
|
+
* @property {Record<string, unknown>} [mutated_input] replacement tool input
|
|
149
|
+
* @property {string} [additional_context] extra context to splice into the agent's stream
|
|
150
|
+
* @property {string} [reason] human-readable rationale (shown on deny/ask)
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The translator for one agent's protocol. `parse` maps a native event to a
|
|
155
|
+
* {@link ToolCallEvent} (never throwing on unmodelled input, stamping the
|
|
156
|
+
* integration mode / enforcement flags on `meta`); `render` maps a {@link Verdict}
|
|
157
|
+
* to that agent's native transport ({@link NativeResponse}), not just a JSON body.
|
|
158
|
+
* @typedef {object} Adapter
|
|
159
|
+
* @property {string} AGENT
|
|
160
|
+
* @property {"external_hook"|"in_process"|"observe_only"} INTEGRATION_MODE
|
|
161
|
+
* @property {(native: any) => ToolCallEvent} parse
|
|
162
|
+
* @property {(verdict: Verdict, event: ToolCallEvent, options?: { soleGate?: boolean }) => NativeResponse} render
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build a normalized {@link ToolCallEvent}, stamping the schema version. Pure —
|
|
167
|
+
* adapters pass already-normalized parts. `response` is omitted unless defined
|
|
168
|
+
* so a pre_tool event has no `response` key at all.
|
|
169
|
+
* @param {{ event: string, tool: string|null, input: Record<string, unknown>, response?: unknown, this_call_vetoable: boolean, meta: EventMeta }} parts
|
|
170
|
+
* @returns {ToolCallEvent}
|
|
171
|
+
*/
|
|
172
|
+
export function makeEvent({
|
|
173
|
+
event,
|
|
174
|
+
tool,
|
|
175
|
+
input,
|
|
176
|
+
response,
|
|
177
|
+
this_call_vetoable,
|
|
178
|
+
meta,
|
|
179
|
+
}) {
|
|
180
|
+
/** @type {ToolCallEvent} */
|
|
181
|
+
const evt = {
|
|
182
|
+
schema_version: SCHEMA_VERSION,
|
|
183
|
+
event: /** @type {ToolCallEvent["event"]} */ (event),
|
|
184
|
+
tool,
|
|
185
|
+
input,
|
|
186
|
+
this_call_vetoable,
|
|
187
|
+
meta,
|
|
188
|
+
};
|
|
189
|
+
if (response !== undefined) evt.response = response;
|
|
190
|
+
return evt;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate and normalize a {@link Verdict}. The decision must be one of
|
|
195
|
+
* allow/deny/ask — a verdict is produced internally, so an out-of-range
|
|
196
|
+
* decision is a bug and throws (fail loud), unlike the pass-through tolerance
|
|
197
|
+
* `parse` extends to untrusted upstream events. Returns a fresh object carrying
|
|
198
|
+
* only the modeled optional fields that are present; never mutates its input.
|
|
199
|
+
* @param {Verdict} verdict
|
|
200
|
+
* @returns {Verdict}
|
|
201
|
+
*/
|
|
202
|
+
export function normalizeVerdict(verdict) {
|
|
203
|
+
const { decision } = verdict;
|
|
204
|
+
if (
|
|
205
|
+
decision !== Decision.ALLOW &&
|
|
206
|
+
decision !== Decision.DENY &&
|
|
207
|
+
decision !== Decision.ASK
|
|
208
|
+
) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`control-plane: invalid verdict decision ${JSON.stringify(decision)}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
/** @type {Verdict} */
|
|
214
|
+
const out = { decision };
|
|
215
|
+
if (verdict.mutated_input !== undefined)
|
|
216
|
+
out.mutated_input = verdict.mutated_input;
|
|
217
|
+
if (verdict.additional_context !== undefined)
|
|
218
|
+
out.additional_context = verdict.additional_context;
|
|
219
|
+
if (verdict.reason !== undefined) out.reason = verdict.reason;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Return a shallow copy of `native` with the `consumed` keys removed — the
|
|
225
|
+
* unmodelled remainder an adapter carries in `meta.passthrough` so an additive
|
|
226
|
+
* upstream field survives instead of being silently dropped.
|
|
227
|
+
* @param {Record<string, unknown>} native
|
|
228
|
+
* @param {Set<string>} consumed
|
|
229
|
+
* @returns {Record<string, unknown>}
|
|
230
|
+
*/
|
|
231
|
+
export function collectPassthrough(native, consumed) {
|
|
232
|
+
/** @type {Record<string, unknown>} */
|
|
233
|
+
const rest = {};
|
|
234
|
+
for (const [key, val] of Object.entries(native)) {
|
|
235
|
+
if (!consumed.has(key)) rest[key] = val;
|
|
236
|
+
}
|
|
237
|
+
return rest;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Coercion primitives ─────────────────────────────────────────────────────
|
|
241
|
+
// Adapters read untrusted native JSON: a field may be absent, null, or the wrong
|
|
242
|
+
// type. These coerce to a safe shape (never throw) so a malformed field degrades
|
|
243
|
+
// to a well-defined default instead of taking the parse down.
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The value if it is a plain (non-array) object, else `{}`.
|
|
247
|
+
* @param {unknown} value
|
|
248
|
+
* @returns {Record<string, unknown>}
|
|
249
|
+
*/
|
|
250
|
+
export function asObject(value) {
|
|
251
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
252
|
+
? /** @type {Record<string, unknown>} */ (value)
|
|
253
|
+
: {};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The value if it is a string, else `null`.
|
|
258
|
+
* @param {unknown} value
|
|
259
|
+
* @returns {string|null}
|
|
260
|
+
*/
|
|
261
|
+
export function asStringOrNull(value) {
|
|
262
|
+
return typeof value === "string" ? value : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The value if it is a string, else `fallback`.
|
|
267
|
+
* @param {unknown} value
|
|
268
|
+
* @param {string} fallback
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
export function asString(value, fallback) {
|
|
272
|
+
return typeof value === "string" ? value : fallback;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Assemble a {@link NativeResponse}, omitting absent optional channels so a
|
|
277
|
+
* pure exit-code transport carries no `stdout` key and vice versa.
|
|
278
|
+
* @param {{ transport: string, exit_code: number, enforced: boolean, stdout?: unknown, throw_?: {message:string}, fallback?: ConfigFallback }} parts
|
|
279
|
+
* @returns {NativeResponse}
|
|
280
|
+
*/
|
|
281
|
+
export function nativeResponse({
|
|
282
|
+
transport,
|
|
283
|
+
exit_code,
|
|
284
|
+
enforced,
|
|
285
|
+
stdout,
|
|
286
|
+
throw_,
|
|
287
|
+
fallback,
|
|
288
|
+
}) {
|
|
289
|
+
/** @type {NativeResponse} */
|
|
290
|
+
const out = {
|
|
291
|
+
transport: /** @type {NativeResponse["transport"]} */ (transport),
|
|
292
|
+
exit_code,
|
|
293
|
+
enforced,
|
|
294
|
+
};
|
|
295
|
+
if (stdout !== undefined) out.stdout = stdout;
|
|
296
|
+
if (throw_ !== undefined) out.throw_ = throw_;
|
|
297
|
+
if (fallback !== undefined) out.fallback = fallback;
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"agent": "amp",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"name": "delegate Bash, exit-code transport, all verdicts",
|
|
6
|
+
"native": {
|
|
7
|
+
"tool": "Bash",
|
|
8
|
+
"input": {
|
|
9
|
+
"command": "ls -la"
|
|
10
|
+
},
|
|
11
|
+
"session_id": "a1",
|
|
12
|
+
"cwd": "/w"
|
|
13
|
+
},
|
|
14
|
+
"event": {
|
|
15
|
+
"schema_version": 1,
|
|
16
|
+
"event": "pre_tool",
|
|
17
|
+
"tool": "Bash",
|
|
18
|
+
"input": {
|
|
19
|
+
"command": "ls -la"
|
|
20
|
+
},
|
|
21
|
+
"this_call_vetoable": true,
|
|
22
|
+
"meta": {
|
|
23
|
+
"agent": "amp",
|
|
24
|
+
"native_event": "delegate",
|
|
25
|
+
"integration_mode": "external_hook",
|
|
26
|
+
"primary_gate_present": true,
|
|
27
|
+
"passthrough": {},
|
|
28
|
+
"session_id": "a1",
|
|
29
|
+
"cwd": "/w"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"render": {
|
|
33
|
+
"allow": {
|
|
34
|
+
"verdict": {
|
|
35
|
+
"decision": "allow"
|
|
36
|
+
},
|
|
37
|
+
"native": {
|
|
38
|
+
"transport": "external_hook",
|
|
39
|
+
"exit_code": 0,
|
|
40
|
+
"enforced": false
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"deny": {
|
|
44
|
+
"verdict": {
|
|
45
|
+
"decision": "deny",
|
|
46
|
+
"reason": "blocked"
|
|
47
|
+
},
|
|
48
|
+
"native": {
|
|
49
|
+
"transport": "external_hook",
|
|
50
|
+
"exit_code": 2,
|
|
51
|
+
"enforced": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"ask": {
|
|
55
|
+
"verdict": {
|
|
56
|
+
"decision": "ask",
|
|
57
|
+
"reason": "confirm"
|
|
58
|
+
},
|
|
59
|
+
"native": {
|
|
60
|
+
"transport": "external_hook",
|
|
61
|
+
"exit_code": 1,
|
|
62
|
+
"enforced": false
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"mutation": {
|
|
66
|
+
"verdict": {
|
|
67
|
+
"decision": "allow",
|
|
68
|
+
"mutated_input": {
|
|
69
|
+
"command": "ls"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"native": {
|
|
73
|
+
"transport": "external_hook",
|
|
74
|
+
"exit_code": 0,
|
|
75
|
+
"enforced": false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "delegate Read, bare meta + passthrough",
|
|
82
|
+
"native": {
|
|
83
|
+
"tool": "Read",
|
|
84
|
+
"input": {
|
|
85
|
+
"file_path": "/x"
|
|
86
|
+
},
|
|
87
|
+
"extra_meta": 7
|
|
88
|
+
},
|
|
89
|
+
"event": {
|
|
90
|
+
"schema_version": 1,
|
|
91
|
+
"event": "pre_tool",
|
|
92
|
+
"tool": "Read",
|
|
93
|
+
"input": {
|
|
94
|
+
"file_path": "/x"
|
|
95
|
+
},
|
|
96
|
+
"this_call_vetoable": true,
|
|
97
|
+
"meta": {
|
|
98
|
+
"agent": "amp",
|
|
99
|
+
"native_event": "delegate",
|
|
100
|
+
"integration_mode": "external_hook",
|
|
101
|
+
"primary_gate_present": true,
|
|
102
|
+
"passthrough": {
|
|
103
|
+
"extra_meta": 7
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"render": {
|
|
108
|
+
"allow": {
|
|
109
|
+
"verdict": {
|
|
110
|
+
"decision": "allow"
|
|
111
|
+
},
|
|
112
|
+
"native": {
|
|
113
|
+
"transport": "external_hook",
|
|
114
|
+
"exit_code": 0,
|
|
115
|
+
"enforced": false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|