@piagent/platform 1.2.0 → 1.2.2

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -202,6 +202,24 @@ export function parseRemoteUrl(value) {
202
202
  } catch {
203
203
  throw new McpEntryError(`--url is not a valid URL: ${value}`);
204
204
  }
205
+ // The same rule the rest of this file applies to `--env` and `--header`. An
206
+ // address is not a safer place to keep a token than a header is: it goes into
207
+ // the same committed file, and it additionally tends to reach request logs and
208
+ // shell history. The refusal is masked so the error itself does not repeat it.
209
+ if (url.username || url.password) {
210
+ throw new McpEntryError(
211
+ "--url must not carry credentials in the address. Use --bearer-token-env-var, " +
212
+ `or --header with a \${VAR} reference. Received: ${maskUrlCredentials(url.href)}`
213
+ );
214
+ }
215
+ for (const [name, item] of url.searchParams) {
216
+ if (credentialLikeName(name) && !ENV_REFERENCE.test(item)) {
217
+ throw new McpEntryError(
218
+ `--url query parameter ${name} looks like a credential. Use --bearer-token-env-var, ` +
219
+ `or give it a \${VAR} reference.`
220
+ );
221
+ }
222
+ }
205
223
  if (url.protocol === "https:") return url;
206
224
  if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url;
207
225
  throw new McpEntryError(
@@ -209,6 +227,35 @@ export function parseRemoteUrl(value) {
209
227
  );
210
228
  }
211
229
 
230
+ /**
231
+ * A URL with anything credential-shaped in it replaced. `add` refuses to write
232
+ * such a URL, but a config written by hand or by an older version can still hold
233
+ * one, and display is exactly where it would leak: `list` and `get` output is
234
+ * meant to be pasteable into an issue.
235
+ *
236
+ * A value that does not parse as a URL is returned untouched. Guessing at the
237
+ * structure of something that is not a URL would be a way to mangle a value
238
+ * without protecting anything.
239
+ *
240
+ * @param {unknown} value
241
+ * @returns {unknown}
242
+ */
243
+ export function maskUrlCredentials(value) {
244
+ if (typeof value !== "string") return value;
245
+ let url;
246
+ try {
247
+ url = new URL(value);
248
+ } catch {
249
+ return value;
250
+ }
251
+ if (url.password) url.password = "*****";
252
+ if (url.username) url.username = "*****";
253
+ for (const [name, item] of [...url.searchParams]) {
254
+ if (credentialLikeName(name) && !ENV_REFERENCE.test(item)) url.searchParams.set(name, "*****");
255
+ }
256
+ return url.href;
257
+ }
258
+
212
259
  /** @param {string} hostname @returns {boolean} */
213
260
  function isLoopbackHost(hostname) {
214
261
  const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
@@ -236,6 +283,10 @@ export function maskServerEntry(entry) {
236
283
  );
237
284
  continue;
238
285
  }
286
+ if (key === "url") {
287
+ out[key] = maskUrlCredentials(value);
288
+ continue;
289
+ }
239
290
  out[key] = value;
240
291
  }
241
292
  return out;
@@ -1,6 +1,12 @@
1
- import { REPOSITORY_SCOPES, SCOPES, collectServers } from "./mcp-config-layers.js";
1
+ import {
2
+ SCOPES,
3
+ collectServers,
4
+ repositoryImportDeclarations,
5
+ unreadableLayers,
6
+ unverifiableMcpConfig
7
+ } from "./mcp-config-layers.js";
2
8
  import { approvalState } from "./mcp-approval-store.js";
3
- import { maskServerEntry } from "./mcp-server-entry.js";
9
+ import { maskServerEntry, maskUrlCredentials } from "./mcp-server-entry.js";
4
10
  import { evaluateServerReadiness } from "./mcp-auth-readiness.js";
5
11
  import { CATALOG_PREREQUISITES } from "./mcp-server-catalog.js";
6
12
 
@@ -24,7 +30,9 @@ export class McpViewError extends Error {}
24
30
  * @returns {string}
25
31
  */
26
32
  export function describeTarget(entry, limit = 56) {
27
- if (typeof entry.url === "string") return entry.url;
33
+ // Masked here as well as in the detail view. This is the string that goes in
34
+ // the `list` table, which is the output most likely to be screenshotted.
35
+ if (typeof entry.url === "string") return String(maskUrlCredentials(entry.url));
28
36
  const argv = [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].filter((item) => typeof item === "string");
29
37
  const text = argv.join(" ");
30
38
  return text.length > limit ? `${text.slice(0, limit - 3)}...` : text;
@@ -58,6 +66,39 @@ export function blockedRows(rows) {
58
66
  return rows.filter((row) => row.readiness.state !== "ready" && row.readiness.state !== "oauth");
59
67
  }
60
68
 
69
+ /**
70
+ * Everything any surface needs to answer "what is MCP doing here right now".
71
+ *
72
+ * The three surfaces — the guard, `/piagent-mcp`, and the terminal CLI — used to
73
+ * assemble this themselves out of four separate calls, and each one picked a
74
+ * different subset. That is how `list` came to print "No MCP servers configured"
75
+ * and exit 0 in a session where the guard was refusing every tool call: the
76
+ * listing was reading rows, and the thing that made the answer wrong was not a
77
+ * row. Everything that decides whether MCP works is gathered here once.
78
+ *
79
+ * `blockingConfig` is the part that outranks per-server state. While any of it
80
+ * stands, no server is usable whatever its own readiness says, so a caller that
81
+ * reports readiness without reporting this is giving an answer that is locally
82
+ * true and globally wrong.
83
+ *
84
+ * @param {{projectPath: string, scopes?: string[]}} options
85
+ */
86
+ export function sessionMcpState(options) {
87
+ const { projectPath, scopes } = options;
88
+ const rows = collectServerRows({ projectPath, scopes });
89
+ const blockingConfig = unverifiableMcpConfig({ projectPath });
90
+ return {
91
+ rows,
92
+ blocked: blockedRows(rows),
93
+ blockingConfig,
94
+ unreadable: unreadableLayers({ projectPath }),
95
+ imports: repositoryImportDeclarations({ projectPath }),
96
+ // One boolean rather than each caller re-deriving the rule. `list --json`
97
+ // read `servers: []` as success while this was true.
98
+ usable: blockingConfig.length === 0
99
+ };
100
+ }
101
+
61
102
  /**
62
103
  * One named server, resolved to a single scope. `listHint` is the caller's own
63
104
  * way of listing servers, so the error names a command the reader can actually
@@ -81,7 +122,11 @@ export function describeServer(options) {
81
122
  );
82
123
  }
83
124
  const row = matches[0];
84
- const approval = REPOSITORY_SCOPES.has(row.scope)
125
+ // `requiresApproval`, not the scope. A server imported into a `global` config
126
+ // from a repository-relative kind carries the scope of the file that declared
127
+ // the import and still needs a decision here, so keying off the scope showed
128
+ // no approval line for exactly the servers whose approval is least obvious.
129
+ const approval = row.requiresApproval
85
130
  ? approvalState({ projectPath, name, entry: row.entry })
86
131
  : undefined;
87
132
  return { ...row, masked: maskServerEntry(row.entry), approval };
@@ -134,6 +179,12 @@ export function formatServerDetail(detail, options = {}) {
134
179
  detail.name,
135
180
  ` scope: ${detail.scope}`,
136
181
  ` file: ${detail.file}`,
182
+ // Only for a server that came through `imports`. Without it the `scope` and
183
+ // `file` lines read as a config this tool can edit, and every write command
184
+ // suggested against them fails or edits the wrong file.
185
+ ...(detail.origin?.startsWith("import:")
186
+ ? [` origin: ${detail.origin} (declared by the ${detail.scope} config; edit ${detail.file} directly)`]
187
+ : []),
137
188
  ` transport: ${detail.transport}`
138
189
  ];
139
190
  for (const key of DETAIL_FIELDS) {
@@ -0,0 +1,90 @@
1
+ // How the adapter names the tools it exposes, mirrored here so a tool call can
2
+ // be traced back to the server behind it.
3
+ //
4
+ // The adapter builds a prefix from the server name and joins it to the tool name
5
+ // with an underscore. With the default `toolPrefix: "server"` a server called
6
+ // `github` exposes `github_create_issue`. There is no `mcp__` anywhere in that
7
+ // scheme, which is why a gate written against `mcp__<server>__<tool>` never
8
+ // fired: it was watching for a name shape nothing produces.
9
+ //
10
+ // `none` is the case with no answer. It strips the prefix entirely, so the tool
11
+ // arrives under whatever name the server gave it and carries no evidence of
12
+ // where it came from. Attribution is impossible by construction, which makes the
13
+ // combination of that setting with `directTools` in a repository-carried config
14
+ // something to refuse rather than something to parse.
15
+
16
+ /** Prefix modes the adapter accepts. */
17
+ export const TOOL_PREFIX_MODES = new Set(["server", "none", "short"]);
18
+
19
+ /**
20
+ * The prefix a server's tools carry under one mode, matching the adapter's own
21
+ * derivation. An empty string means the mode erases the origin.
22
+ * @param {string} serverName
23
+ * @param {string} mode
24
+ * @returns {string}
25
+ */
26
+ export function serverPrefix(serverName, mode) {
27
+ if (mode === "none") return "";
28
+ if (mode === "short") {
29
+ const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
30
+ return short || "mcp";
31
+ }
32
+ return serverName.replace(/-/g, "_");
33
+ }
34
+
35
+ /**
36
+ * Every prefix a server's tools could arrive under. All prefixing modes are
37
+ * covered rather than just the configured one, because the setting lives in the
38
+ * same file as the servers: a repository that wants a different mode can simply
39
+ * declare it, so trusting the declared value to decide what to inspect would
40
+ * hand the choice back to the thing being gated.
41
+ * @param {string} serverName
42
+ * @returns {string[]}
43
+ */
44
+ export function serverPrefixes(serverName) {
45
+ const prefixes = new Set();
46
+ for (const mode of ["server", "short"]) {
47
+ const prefix = serverPrefix(serverName, mode);
48
+ if (prefix) prefixes.add(prefix);
49
+ }
50
+ return [...prefixes];
51
+ }
52
+
53
+ /**
54
+ * Which of the given servers exposes this tool, or undefined when the name
55
+ * carries no prefix belonging to any of them.
56
+ *
57
+ * The longest match wins. Servers named `db` and `db_admin` both prefix
58
+ * `db_admin_query`, and answering `db` there would check the wrong server's
59
+ * approval — the more specific prefix is the one that actually produced it.
60
+ *
61
+ * @param {string} toolName
62
+ * @param {Iterable<string>} serverNames
63
+ * @returns {string|undefined}
64
+ */
65
+ export function attributeDirectTool(toolName, serverNames) {
66
+ if (typeof toolName !== "string" || toolName.length === 0) return undefined;
67
+ let best;
68
+ let bestLength = 0;
69
+ for (const name of serverNames) {
70
+ for (const prefix of serverPrefixes(name)) {
71
+ if (prefix.length <= bestLength) continue;
72
+ if (!toolName.startsWith(`${prefix}_`)) continue;
73
+ best = name;
74
+ bestLength = prefix.length;
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+
80
+ /**
81
+ * Whether a config's settings put tools on the model under names that cannot be
82
+ * traced to a server. Both halves are required: without `directTools` every call
83
+ * goes through the proxy, which names its server explicitly.
84
+ * @param {Record<string, unknown>} settings
85
+ * @returns {boolean}
86
+ */
87
+ export function erasesToolOrigin(settings) {
88
+ if (!settings || typeof settings !== "object") return false;
89
+ return settings.directTools === true && settings.toolPrefix === "none";
90
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -63,6 +63,7 @@ Mandatory flow:
63
63
  16. Run the exact FE verify command from the profile/task through Pi bash and record the observed result with `piagent_verify_record`. Ad-hoc commands are advisory only and will not satisfy the passing gate.
64
64
  17. Record handoff with `piagent_trace_record`.
65
65
  18. Call `piagent_task_gate_check`; if it fails, report blocked/partial instead of done.
66
+ 19. Do not paste full test/build/tool logs into chat or final output. Summarize the signal, quote only relevant failing lines, and use `/piagent-logs` or a narrower command when oversized output has been compacted.
66
67
 
67
68
  For generic projects, use profile `be-readonly-fe` when the repo policy is “BE scout only, FE write allowed”.
68
69
 
@@ -34,6 +34,7 @@ Mandatory flow:
34
34
  9. Explain context overflow prevention when relevant:
35
35
  - `/task-preflight` checks whether to proceed, compact, or fresh-session.
36
36
  - `/fresh-task`, `/fresh-scout`, and `/fresh-be-to-fe` open a new governed session and replay the compact workflow prompt.
37
+ - `/piagent-logs` shows compact-log policy and recent oversized tool-output captures without a model follow-up.
37
38
  - `/scout` is the read-only audit/scout workflow.
38
39
  - `/context-index` shows the compact advisory project context graph without a model follow-up.
39
40
  - `/context-index search <keyword>` searches profile/project/tech/task pointers before broad re-scouting.
@@ -42,6 +42,7 @@ Mandatory flow:
42
42
  12. Implement only the bounded target behavior.
43
43
  13. If runtime behavior changes, update README/docs and add/adjust a decision note when appropriate.
44
44
  14. For source-changing tasks, run the exact verify command from `task.verifyCommands` through Pi bash, record observed verify evidence, trace, then call `piagent_task_gate_check` before final.
45
+ 15. Do not paste full test/build/tool logs into chat or final output. Summarize the signal, quote only relevant failing lines, and use `/piagent-logs` or a narrower command when oversized output has been compacted.
45
46
 
46
47
  Default verification:
47
48
 
@@ -26,6 +26,7 @@ Rules:
26
26
  - security/high-risk only when relevant.
27
27
  If the user asks for a loop, use `/review-loop` semantics or equivalent parent-controlled max-round loop. Continue single-agent for tiny diffs or unavailable subagent tooling.
28
28
  9. Report findings by severity.
29
+ 10. Do not paste full diff/test/tool logs into chat or final output. Summarize the signal, quote only relevant lines, and use `/piagent-logs` or narrower commands when oversized output has been compacted.
29
30
 
30
31
  Output:
31
32
 
@@ -34,6 +34,7 @@ Preflight first:
34
34
  12. Run the exact verify command from the task contract and record with `piagent_verify_record`.
35
35
  13. Record handoff with `piagent_trace_record`.
36
36
  14. Call `piagent_task_gate_check`. If the gate fails, report partial/blocked.
37
+ 15. Do not paste full tool/test logs into chat or final output. Summarize the signal, quote only relevant lines, and use `/piagent-logs` or a narrower command when oversized output has been compacted.
37
38
 
38
39
  Output:
39
40
 
@@ -52,6 +52,7 @@ Mandatory flow:
52
52
  16. Call `piagent_task_gate_check`. If gate fails, final outcome is blocked/partial, not done.
53
53
  17. If the user asks about token/context/cost usage, call `piagent_usage_snapshot`; for exact token/cost totals, tell the user to run `/session` or `piagent-usage <project-path>`.
54
54
  18. If verify cannot run, stop and report the exact blocker. Do not call it done.
55
+ 19. Do not paste full test/build/tool logs into chat or final output. Summarize the signal, quote only the relevant failing lines, and use `/piagent-logs` or a narrower command when oversized output has been compacted.
55
56
 
56
57
  Do not ask the user to paste this mandatory flow. The platform prompt already contains it. If the user pasted the full flow, treat it as boilerplate and extract only the task request.
57
58
 
@@ -34,18 +34,34 @@ DEFAULT_MODEL="openai-codex/gpt-5.5:xhigh"
34
34
  SETTINGS_PATH="${PI_CODING_AGENT_DIR:-"${HOME}/.pi/agent"}/settings.json"
35
35
  DRY_RUN=false
36
36
 
37
+ # A flag whose value is missing swallows the next flag instead. `--settings
38
+ # --dry-run` used to set the settings path to the string "--dry-run", leave
39
+ # DRY_RUN false, then write a file by that name and report success — a run that
40
+ # both skipped the dry run it was asked for and never touched the real settings.
41
+ require_value() {
42
+ local option="$1"
43
+ local value="${2:-}"
44
+ if [[ -z "$value" || "$value" == --* ]]; then
45
+ echo "Missing value for $option" >&2
46
+ exit 2
47
+ fi
48
+ }
49
+
37
50
  while [[ $# -gt 0 ]]; do
38
51
  case "$1" in
39
52
  --preset)
40
- PRESET="${2:-}"
53
+ require_value "$1" "${2:-}"
54
+ PRESET="$2"
41
55
  shift 2
42
56
  ;;
43
57
  --default-model)
44
- DEFAULT_MODEL="${2:-}"
58
+ require_value "$1" "${2:-}"
59
+ DEFAULT_MODEL="$2"
45
60
  shift 2
46
61
  ;;
47
62
  --settings)
48
- SETTINGS_PATH="${2:-}"
63
+ require_value "$1" "${2:-}"
64
+ SETTINGS_PATH="$2"
49
65
  shift 2
50
66
  ;;
51
67
  --dry-run)
@@ -38,22 +38,35 @@ MODEL_SCOPE="none"
38
38
  DRY_RUN=false
39
39
  LIST=false
40
40
 
41
+ require_value() {
42
+ local option="$1"
43
+ local value="${2:-}"
44
+ if [[ -z "$value" || "$value" == --* ]]; then
45
+ echo "Missing value for $option" >&2
46
+ exit 2
47
+ fi
48
+ }
49
+
41
50
  while [[ $# -gt 0 ]]; do
42
51
  case "$1" in
43
52
  --preset)
44
- PRESET="${2:-}"
53
+ require_value "$1" "${2:-}"
54
+ PRESET="$2"
45
55
  shift 2
46
56
  ;;
47
57
  --config)
48
- CONFIG_PATH="${2:-}"
58
+ require_value "$1" "${2:-}"
59
+ CONFIG_PATH="$2"
49
60
  shift 2
50
61
  ;;
51
62
  --settings)
52
- SETTINGS_PATH="${2:-}"
63
+ require_value "$1" "${2:-}"
64
+ SETTINGS_PATH="$2"
53
65
  shift 2
54
66
  ;;
55
67
  --model-scope)
56
- MODEL_SCOPE="${2:-}"
68
+ require_value "$1" "${2:-}"
69
+ MODEL_SCOPE="$2"
57
70
  shift 2
58
71
  ;;
59
72
  --dry-run)
@@ -49,21 +49,28 @@ FORCE_SETTINGS=false
49
49
  SKIP_AGENTS=false
50
50
  SKIP_REVIEW=false
51
51
 
52
+ # Checking only that a value is present lets the next flag serve as the value.
53
+ # `--profile --force-profile` used to name a profile "--force-profile" and leave
54
+ # the force off, so init failed on an unknown profile while the flag the operator
55
+ # actually typed went nowhere.
56
+ require_value() {
57
+ local option="$1"
58
+ local value="${2:-}"
59
+ if [[ -z "$value" || "$value" == --* ]]; then
60
+ echo "Missing value for $option" >&2
61
+ exit 2
62
+ fi
63
+ }
64
+
52
65
  while [[ $# -gt 0 ]]; do
53
66
  case "$1" in
54
67
  --profile)
55
- if [[ $# -lt 2 ]]; then
56
- echo "Missing value for --profile" >&2
57
- exit 2
58
- fi
68
+ require_value "$1" "${2:-}"
59
69
  PROFILE_INPUT="$2"
60
70
  shift 2
61
71
  ;;
62
72
  --package-source)
63
- if [[ $# -lt 2 ]]; then
64
- echo "Missing value for --package-source" >&2
65
- exit 2
66
- fi
73
+ require_value "$1" "${2:-}"
67
74
  PACKAGE_SOURCE="$2"
68
75
  shift 2
69
76
  ;;