forgeos 0.1.0-alpha.20 → 0.1.0-alpha.21
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/AGENTS.md +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/adapters/java/target/forge-java-adapter-0.1.0-alpha.11.jar +0 -0
- package/adapters/java-spring-boot-starter/target/forge-java-spring-boot-starter-0.1.0-alpha.11.jar +0 -0
- package/docs/changelog.md +30 -0
- package/examples/java-billing/target/java-billing-0.1.0-alpha.11-all.jar +0 -0
- package/examples/java-billing/target/java-billing-0.1.0-alpha.11.jar +0 -0
- package/package.json +1 -1
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-memory/bridge.ts +57 -4
- package/src/forge/agent-memory/sources/codex-hook-runner.mjs +191 -2
- package/src/forge/brownfield-import/index.ts +68 -3
- package/src/forge/brownfield-import/types.ts +1 -1
- package/src/forge/version.ts +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.21 input=e2f2a360a9fd4118a2d21dd1353365628fe3927c40f9f9ce870f448f3e221f90 content=0d493cf0e41b71cb652d5e0e1b0c1f83d2a1281b748321f0b00f0773ba93074e
|
|
2
2
|
# AGENTS.md
|
|
3
3
|
|
|
4
4
|
<!-- forge-generated:start -->
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# forgeos
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Harden Codex hook queue privacy and brownfield import classification.
|
|
8
|
+
|
|
9
|
+
- Queue new Codex hook events as redacted payloads instead of storing raw prompts, tool inputs, tool responses, or transcripts in `.forge/agent/events.ndjson`.
|
|
10
|
+
- Compact consumed hook queue history into redacted `.history` lines so old raw queue entries are not copied forward during drain retention.
|
|
11
|
+
- Scope brownfield route classification to the detected route handler, so read-only GET handlers are not marked command-like because a sibling route in the same file writes state.
|
|
12
|
+
- Mark read-shaped `POST /search`, `/query`, `/filter`, `/lookup`, and `/graphql` routes as `command-candidate` with `ambiguous-post-query` risk instead of treating them as normal writes.
|
|
13
|
+
- Sync the public docs changelog/CLI reference and clarify the alpha/latest npm dist-tag policy.
|
|
14
|
+
|
|
3
15
|
## 0.1.0-alpha.20
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -413,7 +413,7 @@ Configure npm Trusted Publisher for package `forgeos`:
|
|
|
413
413
|
| Environment | blank |
|
|
414
414
|
| Allowed action | `npm publish` |
|
|
415
415
|
|
|
416
|
-
Do not add `NPM_TOKEN` for normal
|
|
416
|
+
Do not add `NPM_TOKEN` for normal alpha publishes. Alpha releases publish with the `alpha` dist-tag through npm OIDC Trusted Publisher so prerelease builds do not become `latest` accidentally. Configure `NPM_TOKEN` only when maintainers intentionally want the workflow to promote `latest` with `npm dist-tag add forgeos@<version> latest`; otherwise that step is skipped and `latest` may lag behind `alpha` during hardening. Use `release:publish-local-alpha -- --dry-run` only to validate the staged tarball locally; real npm publishing should go through `release:publish-alpha`, which dispatches `publish.yml` and uses npm OIDC Trusted Publisher. The workflow checks whether the package version already exists before installing dependencies or running tests, then uses `id-token: write`, Node 24/npm 11+, and provenance for the actual publish. `npm run release:smoke` runs `npm pack`, creates a fresh app with the packed tarball, installs dependencies, runs `forge dev --once --json`, and verifies the app smoke path.
|
|
417
417
|
|
|
418
418
|
## Milestone History
|
|
419
419
|
|
|
Binary file
|
package/adapters/java-spring-boot-starter/target/forge-java-spring-boot-starter-0.1.0-alpha.11.jar
CHANGED
|
Binary file
|
package/docs/changelog.md
CHANGED
|
@@ -6,6 +6,36 @@ The canonical source file in the repository is `CHANGELOG.md`.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.1.0-alpha.21
|
|
10
|
+
|
|
11
|
+
Alpha.21 hardens external-agent privacy and brownfield import polish:
|
|
12
|
+
|
|
13
|
+
- Codex hook runner queue entries now store redacted payloads instead of raw
|
|
14
|
+
prompts, tool inputs, tool responses, or transcripts.
|
|
15
|
+
- Consumed hook queue history is compacted as redacted `.history` entries, so
|
|
16
|
+
old raw queue lines are not copied forward during retention.
|
|
17
|
+
- Brownfield import now scopes write/side-effect heuristics to the detected
|
|
18
|
+
route handler when possible, preventing sibling mutating routes from making a
|
|
19
|
+
read-only GET route look command-like.
|
|
20
|
+
- Read-shaped `POST /search`, `/query`, `/filter`, `/lookup`, and `/graphql`
|
|
21
|
+
routes are emitted as `command-candidate` with `ambiguous-post-query` risk
|
|
22
|
+
until a human review decides whether they should become Forge queries or
|
|
23
|
+
commands.
|
|
24
|
+
- CLI/reference docs now include the CAIR agent protocol and clarify the
|
|
25
|
+
`alpha`/`latest` npm dist-tag policy.
|
|
26
|
+
|
|
27
|
+
## 0.1.0-alpha.20
|
|
28
|
+
|
|
29
|
+
Generated-change and hook queue fixes:
|
|
30
|
+
|
|
31
|
+
- Fixed generated-change diagnostics for `AGENTS.md` generated blocks and
|
|
32
|
+
`.forge/agent/context.json`.
|
|
33
|
+
- Skipped probe, invalid, and out-of-workspace queued hook events during Agent
|
|
34
|
+
Memory drain, and bounded large hook queue inspection.
|
|
35
|
+
- Preserved empty stdio command arguments, diagnosed malformed command strings,
|
|
36
|
+
and supported structured `service.commandArgs` in external manifests.
|
|
37
|
+
- Included the basic example client demo in typecheck coverage.
|
|
38
|
+
|
|
9
39
|
## 0.1.0-alpha.19
|
|
10
40
|
|
|
11
41
|
Alpha hardening:
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.
|
|
1
|
+
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.21","releaseId":"forgeos@0.1.0-alpha.21+unknown","schemaVersion":"0.1.0"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.21 input=e2f2a360a9fd4118a2d21dd1353365628fe3927c40f9f9ce870f448f3e221f90 content=dcf914c56ed71bee812d0015d689e1dbf4e952b17ad03cd835f396f5fc8fee1a
|
|
2
2
|
export const releaseManifest = {
|
|
3
3
|
"defaultProvider": "local",
|
|
4
4
|
"diagnostics": [],
|
|
@@ -19,7 +19,7 @@ export const releaseManifest = {
|
|
|
19
19
|
"custom"
|
|
20
20
|
],
|
|
21
21
|
"packageName": "forgeos",
|
|
22
|
-
"packageVersion": "0.1.0-alpha.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
22
|
+
"packageVersion": "0.1.0-alpha.21",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.21+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
|
@@ -4,6 +4,7 @@ import { createDiagnostic } from "../compiler/diagnostics/create.ts";
|
|
|
4
4
|
import { createDeltaId } from "../delta/ids.ts";
|
|
5
5
|
import { DeltaStore, DeltaStoreBusyError, describeDeltaStoreBusy, summarizeDeltaStoreBusy } from "../delta/store.ts";
|
|
6
6
|
import { extractAgentEventBindings, normalizeAgentEvent, summarizeAgentEvent } from "./normalize.ts";
|
|
7
|
+
import { redactAgentPayload } from "./redaction.ts";
|
|
7
8
|
import { buildAgentMemoryContext } from "./context-pack.ts";
|
|
8
9
|
import { claudeCodeInstallFiles, claudeCodeInstallResult } from "./sources/claude-code.ts";
|
|
9
10
|
import { codexInstallFiles, codexInstallResult, privacyDefaults } from "./sources/codex.ts";
|
|
@@ -497,15 +498,62 @@ function compactAgentMemoryQueueFile(options: {
|
|
|
497
498
|
}
|
|
498
499
|
mkdirSync(dirname(historyFile), { recursive: true });
|
|
499
500
|
const existingHistory = existsSync(historyFile) ? readFileSync(historyFile) : Buffer.alloc(0);
|
|
501
|
+
const redactedConsumedHistory = redactedQueueHistoryBuffer(originalConsumed);
|
|
500
502
|
writeFileSync(
|
|
501
503
|
historyFile,
|
|
502
|
-
trimBufferStart(Buffer.concat([existingHistory,
|
|
504
|
+
trimBufferStart(Buffer.concat([existingHistory, redactedConsumedHistory]), options.historyMaxBytes),
|
|
503
505
|
);
|
|
504
506
|
writeFileSync(options.watchFile, currentBuffer.subarray(options.consumedOffset));
|
|
505
507
|
writeQueueCheckpoint(options.watchFile, 0);
|
|
506
508
|
return { compacted: true, historyFile };
|
|
507
509
|
}
|
|
508
510
|
|
|
511
|
+
function redactedQueueHistoryBuffer(consumedBuffer: Buffer): Buffer {
|
|
512
|
+
const { complete } = splitCompleteJsonLines(consumedBuffer);
|
|
513
|
+
const lines: string[] = [];
|
|
514
|
+
for (const line of complete) {
|
|
515
|
+
if (!line.raw.trim()) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const parsed = normalizeRawInput(line.raw);
|
|
519
|
+
if (!parsed) {
|
|
520
|
+
lines.push(JSON.stringify({
|
|
521
|
+
forgeHookQueueV1: true,
|
|
522
|
+
historyRedacted: true,
|
|
523
|
+
rawStored: false,
|
|
524
|
+
payloadRedacted: true,
|
|
525
|
+
payload: { _parseError: true },
|
|
526
|
+
}));
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
lines.push(JSON.stringify(redactedQueueHistoryEntry(parsed)));
|
|
530
|
+
}
|
|
531
|
+
return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function redactedQueueHistoryEntry(parsed: Record<string, unknown>): Record<string, unknown> {
|
|
535
|
+
if (parsed.forgeHookQueueV1 !== true) {
|
|
536
|
+
return {
|
|
537
|
+
historyRedacted: true,
|
|
538
|
+
rawStored: false,
|
|
539
|
+
payloadRedacted: true,
|
|
540
|
+
payload: redactAgentPayload(parsed).value,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const queuedPayload = objectField(parsed, "payload") ?? objectField(parsed, "raw") ?? {};
|
|
544
|
+
return {
|
|
545
|
+
forgeHookQueueV1: true,
|
|
546
|
+
source: typeof parsed.source === "string" ? parsed.source : "codex",
|
|
547
|
+
eventName: typeof parsed.eventName === "string" ? parsed.eventName : undefined,
|
|
548
|
+
workspaceRoot: typeof parsed.workspaceRoot === "string" ? parsed.workspaceRoot : undefined,
|
|
549
|
+
enqueuedAt: typeof parsed.enqueuedAt === "string" ? parsed.enqueuedAt : undefined,
|
|
550
|
+
historyRedacted: true,
|
|
551
|
+
rawStored: false,
|
|
552
|
+
payloadRedacted: true,
|
|
553
|
+
payload: parsed.payloadRedacted === true ? queuedPayload : redactAgentPayload(queuedPayload).value,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
509
557
|
function splitCompleteJsonLines(buffer: Buffer): {
|
|
510
558
|
complete: Array<{ raw: string; endOffset: number }>;
|
|
511
559
|
completeBytes: number;
|
|
@@ -1104,6 +1152,11 @@ function normalizeRawInput(input: unknown): Record<string, unknown> | null {
|
|
|
1104
1152
|
return null;
|
|
1105
1153
|
}
|
|
1106
1154
|
|
|
1155
|
+
function objectField(value: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
|
1156
|
+
const child = value[key];
|
|
1157
|
+
return child && typeof child === "object" && !Array.isArray(child) ? child as Record<string, unknown> : undefined;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1107
1160
|
export async function readStdinJson(options?: { timeoutMs?: number }): Promise<unknown> {
|
|
1108
1161
|
if (process.stdin.isTTY) {
|
|
1109
1162
|
return undefined;
|
|
@@ -1150,15 +1203,15 @@ function parseQueuedHookLine(raw: Record<string, unknown>): {
|
|
|
1150
1203
|
if (raw.forgeHookQueueV1 !== true) {
|
|
1151
1204
|
return null;
|
|
1152
1205
|
}
|
|
1153
|
-
const payload = raw
|
|
1154
|
-
if (!payload
|
|
1206
|
+
const payload = objectField(raw, "payload") ?? objectField(raw, "raw");
|
|
1207
|
+
if (!payload) {
|
|
1155
1208
|
return null;
|
|
1156
1209
|
}
|
|
1157
1210
|
return {
|
|
1158
1211
|
source: typeof raw.source === "string" ? raw.source : "codex",
|
|
1159
1212
|
eventName: typeof raw.eventName === "string" ? raw.eventName : undefined,
|
|
1160
1213
|
workspaceRoot: typeof raw.workspaceRoot === "string" ? raw.workspaceRoot : undefined,
|
|
1161
|
-
payload
|
|
1214
|
+
payload,
|
|
1162
1215
|
};
|
|
1163
1216
|
}
|
|
1164
1217
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Lightweight Codex hook runner — no Forge CLI, no DeltaDB.
|
|
4
|
-
* Reads stdin with a short timeout, enqueues to .forge/agent/events.ndjson, exits.
|
|
4
|
+
* Reads stdin with a short timeout, enqueues a redacted event to .forge/agent/events.ndjson, exits.
|
|
5
5
|
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
7
8
|
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
|
|
@@ -17,6 +18,24 @@ if (!eventName) {
|
|
|
17
18
|
const workspaceRoot = resolve(process.cwd());
|
|
18
19
|
const eventsFile = join(workspaceRoot, ".forge", "agent", "events.ndjson");
|
|
19
20
|
|
|
21
|
+
const RAW_TEXT_KEYS = new Set([
|
|
22
|
+
"prompt",
|
|
23
|
+
"userPrompt",
|
|
24
|
+
"last_assistant_message",
|
|
25
|
+
"lastAssistantMessage",
|
|
26
|
+
"completion",
|
|
27
|
+
"message",
|
|
28
|
+
"transcript",
|
|
29
|
+
"transcript_path",
|
|
30
|
+
"transcriptPath",
|
|
31
|
+
"output",
|
|
32
|
+
"stdout",
|
|
33
|
+
"stderr",
|
|
34
|
+
"result",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const RAW_ARGS_KEYS = new Set(["args", "arguments", "tool_input", "toolInput", "tool_response", "toolResponse", "input"]);
|
|
38
|
+
|
|
20
39
|
function readStdin(timeoutMs) {
|
|
21
40
|
return new Promise((resolveRead) => {
|
|
22
41
|
if (process.stdin.isTTY) {
|
|
@@ -72,13 +91,183 @@ async function main() {
|
|
|
72
91
|
eventName,
|
|
73
92
|
workspaceRoot,
|
|
74
93
|
enqueuedAt: new Date().toISOString(),
|
|
75
|
-
|
|
94
|
+
rawStored: false,
|
|
95
|
+
payloadRedacted: true,
|
|
96
|
+
payload: sanitizePayload(raw, eventName),
|
|
76
97
|
};
|
|
77
98
|
|
|
78
99
|
mkdirSync(dirname(eventsFile), { recursive: true });
|
|
79
100
|
appendFileSync(eventsFile, `${JSON.stringify(entry)}\n`, "utf8");
|
|
80
101
|
}
|
|
81
102
|
|
|
103
|
+
function sanitizePayload(raw, hookEventName) {
|
|
104
|
+
const payload = stripRawPayload(raw);
|
|
105
|
+
if (!payload.hook_event_name) {
|
|
106
|
+
payload.hook_event_name = hookEventName;
|
|
107
|
+
}
|
|
108
|
+
if (!payload.cwd) {
|
|
109
|
+
payload.cwd = workspaceRoot;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toolInput = objectField(raw, "tool_input") ?? objectField(raw, "toolInput");
|
|
113
|
+
const toolResponse = objectField(raw, "tool_response") ?? objectField(raw, "toolResponse");
|
|
114
|
+
const command = stringField(toolInput, "command") ?? stringField(raw, "command");
|
|
115
|
+
if (command) {
|
|
116
|
+
payload.commandHash = hashStable(command);
|
|
117
|
+
payload.commandStored = false;
|
|
118
|
+
payload.commandSummary = summarizeCommand(command);
|
|
119
|
+
payload.commandKind = classifyCommand(stringField(raw, "tool_name") ?? stringField(raw, "toolName"), command);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const description = stringField(toolInput, "description");
|
|
123
|
+
if (description) {
|
|
124
|
+
payload.approvalDescriptionSummary = safeSummary(description, 180);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const exitCode = numberField(toolResponse, "exitCode") ?? numberField(toolResponse, "exit_code") ??
|
|
128
|
+
numberField(raw, "exitCode") ?? numberField(raw, "exit_code");
|
|
129
|
+
if (exitCode !== undefined) {
|
|
130
|
+
payload.exitCode = exitCode;
|
|
131
|
+
payload.resultStatus = exitCode === 0 ? "success" : "failed";
|
|
132
|
+
} else {
|
|
133
|
+
const status = stringField(toolResponse, "status") ?? stringField(raw, "status");
|
|
134
|
+
if (status) {
|
|
135
|
+
payload.resultStatus = status;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const responseSummary = summarizeToolResponse(toolResponse);
|
|
140
|
+
if (responseSummary) {
|
|
141
|
+
payload.responseSummary = responseSummary;
|
|
142
|
+
}
|
|
143
|
+
if (toolResponse) {
|
|
144
|
+
payload.responseHash = hashStable(JSON.stringify(toolResponse));
|
|
145
|
+
payload.responseStored = false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return payload;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stripRawPayload(value) {
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
return value.slice(0, 50).map((item) => stripRawPayload(item));
|
|
154
|
+
}
|
|
155
|
+
if (!value || typeof value !== "object") {
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
const output = {};
|
|
159
|
+
for (const [key, child] of Object.entries(value)) {
|
|
160
|
+
if (RAW_TEXT_KEYS.has(key)) {
|
|
161
|
+
output[`${key}Hash`] = hashStable(typeof child === "string" ? child : JSON.stringify(child ?? null));
|
|
162
|
+
output[`${key}Stored`] = false;
|
|
163
|
+
if (typeof child === "string" && !isPromptLikeKey(key)) {
|
|
164
|
+
const summary = safeSummary(child, 160);
|
|
165
|
+
if (summary) {
|
|
166
|
+
output[`${key}Summary`] = summary;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (RAW_ARGS_KEYS.has(key)) {
|
|
172
|
+
output[`${key}Hash`] = hashStable(JSON.stringify(child ?? null));
|
|
173
|
+
output[`${key}Stored`] = false;
|
|
174
|
+
output[`${key}Shape`] = describeShape(child);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
output[key] = stripRawPayload(child);
|
|
178
|
+
}
|
|
179
|
+
return output;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function objectField(value, key) {
|
|
183
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const child = value[key];
|
|
187
|
+
return child && typeof child === "object" && !Array.isArray(child) ? child : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function stringField(value, key) {
|
|
191
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const child = value[key];
|
|
195
|
+
return typeof child === "string" && child.length > 0 ? child : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function numberField(value, key) {
|
|
199
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
const child = value[key];
|
|
203
|
+
return typeof child === "number" && Number.isFinite(child) ? child : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function describeShape(value) {
|
|
207
|
+
if (Array.isArray(value)) {
|
|
208
|
+
return { kind: "array", length: value.length };
|
|
209
|
+
}
|
|
210
|
+
if (value && typeof value === "object") {
|
|
211
|
+
return {
|
|
212
|
+
kind: "object",
|
|
213
|
+
keys: Object.keys(value).slice(0, 20).sort(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { kind: typeof value };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function summarizeCommand(command) {
|
|
220
|
+
return safeSummary(
|
|
221
|
+
command
|
|
222
|
+
.replace(/--(token|api-key|apikey|password|secret)\s+[^\s]+/giu, "--$1 [REDACTED]")
|
|
223
|
+
.replace(/(["']?)(token|apiKey|api_key|password|secret)(["']?)\s*:\s*(["'])(.*?)\4/giu, "$1$2$3: \"[REDACTED]\""),
|
|
224
|
+
220,
|
|
225
|
+
) ?? "[command redacted]";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function summarizeToolResponse(response) {
|
|
229
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
const text = stringField(response, "stdout") ?? stringField(response, "stderr") ??
|
|
233
|
+
stringField(response, "output") ?? stringField(response, "result");
|
|
234
|
+
return text ? safeSummary(text, 180) : undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function safeSummary(value, maxLength) {
|
|
238
|
+
const normalized = scrubSecretTokens(value).replace(/\s+/gu, " ").trim();
|
|
239
|
+
if (!normalized) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function classifyCommand(toolName, command) {
|
|
246
|
+
if (toolName === "apply_patch" || command.includes("*** Begin Patch")) {
|
|
247
|
+
return "patch";
|
|
248
|
+
}
|
|
249
|
+
if (/^\s*(?:node|npm|bun|pnpm|yarn|forge|git)\b/u.test(command)) {
|
|
250
|
+
return "shell";
|
|
251
|
+
}
|
|
252
|
+
return "unknown";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isPromptLikeKey(key) {
|
|
256
|
+
return key.toLowerCase().includes("prompt") || key.toLowerCase().includes("completion") || key.toLowerCase().includes("message");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function scrubSecretTokens(value) {
|
|
260
|
+
return value
|
|
261
|
+
.replace(/\bsk[-_][A-Za-z0-9_\-.]{8,}\b/gu, "[REDACTED]")
|
|
262
|
+
.replace(/\bnpm_[A-Za-z0-9]{16,}\b/gu, "[REDACTED]")
|
|
263
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED]")
|
|
264
|
+
.replace(/\b(?:xox[baprs]-)[A-Za-z0-9-]{16,}\b/gu, "[REDACTED]");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function hashStable(value) {
|
|
268
|
+
return createHash("sha256").update(value).digest("hex");
|
|
269
|
+
}
|
|
270
|
+
|
|
82
271
|
main()
|
|
83
272
|
.then(() => process.exit(0))
|
|
84
273
|
.catch(() => process.exit(1));
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ImportedDependencyInventory,
|
|
9
9
|
ImportedFrontendCall,
|
|
10
10
|
ImportedInventory,
|
|
11
|
+
ImportedEntryKind,
|
|
11
12
|
ImportedRiskFinding,
|
|
12
13
|
ImportedRiskReport,
|
|
13
14
|
ImportedRoute,
|
|
@@ -365,7 +366,53 @@ function collectEnv(workspaceRoot: string, files: SourceFile[]): ImportedInvento
|
|
|
365
366
|
}
|
|
366
367
|
|
|
367
368
|
function sourceTextForRoute(route: ImportedRoute, files: SourceFile[]): string {
|
|
368
|
-
|
|
369
|
+
const text = files.find((file) => file.relativePath === route.file)?.text ?? "";
|
|
370
|
+
if (!text) {
|
|
371
|
+
return "";
|
|
372
|
+
}
|
|
373
|
+
return scopedSourceTextForRoute(route, text) ?? text;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function scopedSourceTextForRoute(route: ImportedRoute, text: string): string | null {
|
|
377
|
+
if (route.source === "next-app-router" && route.handler) {
|
|
378
|
+
return sliceUntilNextMatch(
|
|
379
|
+
text,
|
|
380
|
+
new RegExp(`export\\s+(?:async\\s+)?function\\s+${escapeRegExp(route.handler)}\\b`, "u"),
|
|
381
|
+
/export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/gu,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (route.source === "express") {
|
|
385
|
+
const method = route.method.toLowerCase();
|
|
386
|
+
const matcher = new RegExp(`\\b(?:app|router)\\s*\\.\\s*${escapeRegExp(method)}\\s*\\(\\s*["'\`]${escapeRegExp(route.path)}["'\`]`, "u");
|
|
387
|
+
return sliceUntilNextMatch(
|
|
388
|
+
text,
|
|
389
|
+
matcher,
|
|
390
|
+
/\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|all)\s*\(\s*["'`][^"'`]+["'`]/giu,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (route.source === "nest") {
|
|
394
|
+
const method = route.method.charAt(0).toUpperCase() + route.method.slice(1).toLowerCase();
|
|
395
|
+
return sliceUntilNextMatch(
|
|
396
|
+
text,
|
|
397
|
+
new RegExp(`@${escapeRegExp(method)}\\s*\\(`, "u"),
|
|
398
|
+
/@(Get|Post|Put|Patch|Delete|All)\s*\(/gu,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function sliceUntilNextMatch(text: string, startPattern: RegExp, nextPattern: RegExp): string | null {
|
|
405
|
+
const start = text.search(startPattern);
|
|
406
|
+
if (start < 0) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
nextPattern.lastIndex = start + 1;
|
|
410
|
+
const next = nextPattern.exec(text);
|
|
411
|
+
return text.slice(start, next?.index ?? text.length);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function escapeRegExp(value: string): string {
|
|
415
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
369
416
|
}
|
|
370
417
|
|
|
371
418
|
function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCandidateEntry, "kind" | "confidence" | "risks" | "evidence" | "needsApproval"> {
|
|
@@ -381,9 +428,17 @@ function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCan
|
|
|
381
428
|
const auth = /(auth|session|currentuser|getserversession|clerk|nextauth|requireuser|requireauth)/u.test(lowerText);
|
|
382
429
|
const tenant = /(tenantid|tenant_id|organizationid|orgid|accountid)/u.test(lowerText);
|
|
383
430
|
const methodUnknown = method === "ANY" || method === "ALL";
|
|
384
|
-
|
|
431
|
+
const ambiguousPostQuery = method === "POST" &&
|
|
432
|
+
/(?:^|\/)(search|query|filter|lookup|graphql)(?:$|\/)/u.test(lowerPath) &&
|
|
433
|
+
!writes &&
|
|
434
|
+
!isDestructive &&
|
|
435
|
+
!external;
|
|
436
|
+
if ((!isQuery && !ambiguousPostQuery) || writes) {
|
|
385
437
|
risks.add("writes-state");
|
|
386
438
|
}
|
|
439
|
+
if (ambiguousPostQuery) {
|
|
440
|
+
risks.add("ambiguous-post-query");
|
|
441
|
+
}
|
|
387
442
|
if (isDestructive) {
|
|
388
443
|
risks.add("destructive");
|
|
389
444
|
}
|
|
@@ -403,6 +458,15 @@ function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCan
|
|
|
403
458
|
risks.add("method-unknown");
|
|
404
459
|
}
|
|
405
460
|
const commandLike = !isQuery || writes || isDestructive || external;
|
|
461
|
+
if (ambiguousPostQuery) {
|
|
462
|
+
return {
|
|
463
|
+
kind: "command-candidate",
|
|
464
|
+
confidence: 0.55,
|
|
465
|
+
risks: Array.from(risks).sort(),
|
|
466
|
+
evidence,
|
|
467
|
+
needsApproval: true,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
406
470
|
return {
|
|
407
471
|
kind: commandLike ? "command" : "query",
|
|
408
472
|
confidence: commandLike ? (isDestructive ? 0.9 : 0.78) : 0.86,
|
|
@@ -412,7 +476,7 @@ function classifyCandidate(route: ImportedRoute, text: string): Pick<ImportedCan
|
|
|
412
476
|
};
|
|
413
477
|
}
|
|
414
478
|
|
|
415
|
-
function nameForCandidate(route: ImportedRoute, kind:
|
|
479
|
+
function nameForCandidate(route: ImportedRoute, kind: ImportedEntryKind): string {
|
|
416
480
|
const nouns = route.path
|
|
417
481
|
.replace(/^\/api\//u, "")
|
|
418
482
|
.replace(/:\w+\*?/gu, "byId")
|
|
@@ -424,6 +488,7 @@ function nameForCandidate(route: ImportedRoute, kind: "command" | "query" | "unk
|
|
|
424
488
|
const method = route.method.toUpperCase();
|
|
425
489
|
const action =
|
|
426
490
|
kind === "query" ? "read" :
|
|
491
|
+
kind === "command-candidate" ? "candidate" :
|
|
427
492
|
method === "POST" ? "create" :
|
|
428
493
|
method === "PUT" || method === "PATCH" ? "update" :
|
|
429
494
|
method === "DELETE" ? "delete" :
|
|
@@ -11,7 +11,7 @@ export interface BrownfieldImportCommandOptions {
|
|
|
11
11
|
|
|
12
12
|
export type ImportedAssurance = "static-scan";
|
|
13
13
|
export type ImportedReviewStatus = "needs-review" | "approved" | "rejected";
|
|
14
|
-
export type ImportedEntryKind = "command" | "query" | "unknown";
|
|
14
|
+
export type ImportedEntryKind = "command" | "command-candidate" | "query" | "unknown";
|
|
15
15
|
export type ImportedRouteSource = "next-app-router" | "next-pages-api" | "express" | "nest" | "unknown";
|
|
16
16
|
|
|
17
17
|
export interface ImportedDependencyInventory {
|
package/src/forge/version.ts
CHANGED