@app-vitals/agent-lib 0.2.1 → 0.3.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/admin-types.ts CHANGED
@@ -1432,6 +1432,134 @@ export interface paths {
1432
1432
  patch?: never;
1433
1433
  trace?: never;
1434
1434
  };
1435
+ "/agents/{id}/crons/{cronId}/runs/{runId}/transcript": {
1436
+ parameters: {
1437
+ query?: never;
1438
+ header?: never;
1439
+ path?: never;
1440
+ cookie?: never;
1441
+ };
1442
+ /**
1443
+ * Get a cron run transcript
1444
+ * @description Returns the stored redacted transcript. `*`, `platform.read` and `platform.ops` callers only: an agent key is 403 even for its own runs. Every read writes an AgentTranscriptRead audit row before the response. 404 when SHIPWRIGHT_TRANSCRIPT_API_ENABLED is not `enabled` or no transcript exists.
1445
+ */
1446
+ get: {
1447
+ parameters: {
1448
+ query?: never;
1449
+ header?: never;
1450
+ path: {
1451
+ id: string;
1452
+ cronId: string;
1453
+ runId: string;
1454
+ };
1455
+ cookie?: never;
1456
+ };
1457
+ requestBody?: never;
1458
+ responses: {
1459
+ /** @description The transcript */
1460
+ 200: {
1461
+ headers: {
1462
+ [name: string]: unknown;
1463
+ };
1464
+ content: {
1465
+ "application/json": components["schemas"]["CronRunTranscript"];
1466
+ };
1467
+ };
1468
+ /** @description Forbidden */
1469
+ 403: {
1470
+ headers: {
1471
+ [name: string]: unknown;
1472
+ };
1473
+ content: {
1474
+ "application/json": components["schemas"]["Error"];
1475
+ };
1476
+ };
1477
+ /** @description No transcript, or transcript API disabled */
1478
+ 404: {
1479
+ headers: {
1480
+ [name: string]: unknown;
1481
+ };
1482
+ content: {
1483
+ "application/json": components["schemas"]["Error"];
1484
+ };
1485
+ };
1486
+ };
1487
+ };
1488
+ /**
1489
+ * Upload a cron run transcript
1490
+ * @description Stores (upserts) the runtime's already-redacted session transcript for a cron run, then keeps only the agent's newest 200 transcripts. Callers: the agent's own key, or `*`. `body` over 1 MiB (UTF-8 bytes) is a 413. 404 unless SHIPWRIGHT_TRANSCRIPT_API_ENABLED is `enabled`, or when the run is not owned by the cron/agent.
1491
+ */
1492
+ put: {
1493
+ parameters: {
1494
+ query?: never;
1495
+ header?: never;
1496
+ path: {
1497
+ id: string;
1498
+ cronId: string;
1499
+ runId: string;
1500
+ };
1501
+ cookie?: never;
1502
+ };
1503
+ requestBody?: {
1504
+ content: {
1505
+ "application/json": components["schemas"]["UploadCronRunTranscriptBody"];
1506
+ };
1507
+ };
1508
+ responses: {
1509
+ /** @description Transcript stored */
1510
+ 200: {
1511
+ headers: {
1512
+ [name: string]: unknown;
1513
+ };
1514
+ content: {
1515
+ "application/json": components["schemas"]["CronRunTranscriptUploadResult"];
1516
+ };
1517
+ };
1518
+ /** @description Bad request */
1519
+ 400: {
1520
+ headers: {
1521
+ [name: string]: unknown;
1522
+ };
1523
+ content: {
1524
+ "application/json": components["schemas"]["Error"];
1525
+ };
1526
+ };
1527
+ /** @description Caller not allowed */
1528
+ 403: {
1529
+ headers: {
1530
+ [name: string]: unknown;
1531
+ };
1532
+ content: {
1533
+ "application/json": components["schemas"]["Error"];
1534
+ };
1535
+ };
1536
+ /** @description Run not found, or transcript API disabled */
1537
+ 404: {
1538
+ headers: {
1539
+ [name: string]: unknown;
1540
+ };
1541
+ content: {
1542
+ "application/json": components["schemas"]["Error"];
1543
+ };
1544
+ };
1545
+ /** @description Transcript body over 1 MiB */
1546
+ 413: {
1547
+ headers: {
1548
+ [name: string]: unknown;
1549
+ };
1550
+ content: {
1551
+ "application/json": components["schemas"]["Error"];
1552
+ };
1553
+ };
1554
+ };
1555
+ };
1556
+ post?: never;
1557
+ delete?: never;
1558
+ options?: never;
1559
+ head?: never;
1560
+ patch?: never;
1561
+ trace?: never;
1562
+ };
1435
1563
  "/agents/{id}/plugins": {
1436
1564
  parameters: {
1437
1565
  query?: never;
@@ -3565,6 +3693,53 @@ export interface components {
3565
3693
  TokensWrapper: {
3566
3694
  tokens: components["schemas"]["AgentToken"][];
3567
3695
  };
3696
+ CronRunTranscriptUploadResult: {
3697
+ /** @example clx1111111111 */
3698
+ runId: string;
3699
+ /** @example 2048 */
3700
+ bytes: number;
3701
+ /** @example false */
3702
+ truncated: boolean;
3703
+ /** @example 0 */
3704
+ redactions: number;
3705
+ };
3706
+ UploadCronRunTranscriptBody: {
3707
+ /**
3708
+ * @description Claude session id the transcript was read from.
3709
+ * @example 7c1c8d1e-0000-4000-8000-000000000000
3710
+ */
3711
+ sessionId?: string;
3712
+ /** @description The already-redacted transcript (JSONL text). Over 1 MiB (UTF-8 bytes) is a 413. Never logged. */
3713
+ body: string;
3714
+ /**
3715
+ * @description True when the runtime dropped earlier lines to fit.
3716
+ * @example false
3717
+ */
3718
+ truncated: boolean;
3719
+ /**
3720
+ * @description How many secrets the runtime redacted.
3721
+ * @example 0
3722
+ */
3723
+ redactions: number;
3724
+ };
3725
+ CronRunTranscript: {
3726
+ /** @example clx1111111111 */
3727
+ runId: string;
3728
+ /** @example clx0987654321 */
3729
+ cronId: string;
3730
+ /** @example null */
3731
+ sessionId: string | null;
3732
+ /** @example 2048 */
3733
+ bytes: number;
3734
+ /** @example false */
3735
+ truncated: boolean;
3736
+ /** @example 0 */
3737
+ redactions: number;
3738
+ /** @example 2026-10-26T12:00:00.000Z */
3739
+ createdAt: string;
3740
+ /** @description Redacted JSONL transcript. */
3741
+ body: string;
3742
+ };
3568
3743
  PluginWrapper: {
3569
3744
  plugin: components["schemas"]["AgentPlugin"];
3570
3745
  };
@@ -3968,6 +4143,7 @@ export interface components {
3968
4143
  phaseMethodology: {
3969
4144
  [key: string]: string | null;
3970
4145
  };
4146
+ features?: components["schemas"]["AgentConfigFeatures"];
3971
4147
  };
3972
4148
  AgentConfigPlugin: {
3973
4149
  /** @example shipwright */
@@ -3975,6 +4151,18 @@ export interface components {
3975
4151
  /** @example shipwright */
3976
4152
  plugin: string;
3977
4153
  };
4154
+ AgentConfigFeatures: {
4155
+ /**
4156
+ * @description True when SHIPWRIGHT_TRANSCRIPT_API_ENABLED is on: the runtime uploads a redacted transcript after each cron run.
4157
+ * @example false
4158
+ */
4159
+ transcriptUpload: boolean;
4160
+ /**
4161
+ * @description Reserved for the repo-access check (COS-44.1).
4162
+ * @example false
4163
+ */
4164
+ repoAccessCheck: boolean;
4165
+ };
3978
4166
  RuntimeError: {
3979
4167
  /** @example Not found */
3980
4168
  error: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-lib",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -26,6 +26,7 @@
26
26
  "./pool-observability": "./pool-observability.ts",
27
27
  "./pricing": "./pricing.ts",
28
28
  "./progress-phases": "./progress-phases.ts",
29
+ "./redact": "./redact.ts",
29
30
  "./request-context": "./request-context.ts",
30
31
  "./secret-env-vars": "./secret-env-vars.ts",
31
32
  "./default-account": "./default-account.ts",
package/redact.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * lib/redact.ts
3
+ * Secret redaction for cron-run transcripts (COS-43.1). Lives in lib/ so the
4
+ * runtime (which redacts before upload) and admin can both import it.
5
+ *
6
+ * Two passes, both returning the redacted text plus how many substitutions
7
+ * were made:
8
+ * - redactValues: the live values of SECRET_ENV_VARS (the same scrub the
9
+ * Sentry hooks use, lib/sentry.ts redactSecrets).
10
+ * - redactShapes: well-known credential shapes (GitHub/Anthropic/OpenAI/
11
+ * Slack/AWS tokens, PEM private keys, bearer tokens, JWTs).
12
+ */
13
+
14
+ import { liveSecretValues, redactSecrets } from "./sentry.ts";
15
+
16
+ export interface RedactResult {
17
+ text: string;
18
+ count: number;
19
+ }
20
+
21
+ type Env = Readonly<Record<string, string | undefined>>;
22
+
23
+ export const REDACTED = "[REDACTED]";
24
+
25
+ const SHAPES: readonly RegExp[] = [
26
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
27
+ /github_pat_[A-Za-z0-9_]{20,}/g,
28
+ /ghp_[A-Za-z0-9]{20,}/g,
29
+ /sk-ant-[A-Za-z0-9_-]{20,}/g,
30
+ // Not preceded by an alphanumeric, so "task-<slug>" is not an OpenAI key.
31
+ /(?<![A-Za-z0-9])sk-[A-Za-z0-9]{20,}/g,
32
+ /xox[abprs]-[A-Za-z0-9-]{10,}/g,
33
+ /AKIA[A-Z0-9]{16}/g,
34
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
35
+ /Bearer [A-Za-z0-9._-]{16,}/g,
36
+ ];
37
+
38
+ /** Replaces the live values of SECRET_ENV_VARS in `env` (default process.env). */
39
+ export function redactValues(
40
+ text: string,
41
+ env: Env = process.env,
42
+ ): RedactResult {
43
+ const secrets = liveSecretValues(env);
44
+ let count = 0;
45
+ for (const secret of secrets) {
46
+ count += text.split(secret).length - 1;
47
+ }
48
+ if (count === 0) return { text, count: 0 };
49
+ return { text: redactSecrets(text, secrets, new WeakSet(), 0), count };
50
+ }
51
+
52
+ /** Replaces well-known credential shapes. */
53
+ export function redactShapes(text: string): RedactResult {
54
+ let count = 0;
55
+ let out = text;
56
+ for (const re of SHAPES) {
57
+ out = out.replace(re, () => {
58
+ count++;
59
+ return REDACTED;
60
+ });
61
+ }
62
+ return { text: out, count };
63
+ }
64
+
65
+ /** redactValues then redactShapes; counts are summed. */
66
+ export function redact(text: string, env: Env = process.env): RedactResult {
67
+ const values = redactValues(text, env);
68
+ const shapes = redactShapes(values.text);
69
+ return { text: shapes.text, count: values.count + shapes.count };
70
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * lib/redact.unit.test.ts
3
+ * Transcript redaction (COS-43.1): live secret values + token shapes.
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { redact, redactShapes, redactValues } from "./redact.ts";
8
+
9
+ const PLANTED: Record<string, string> = {
10
+ "GitHub classic PAT": `ghp_${"A1b2C3d4E5".repeat(3)}`,
11
+ "GitHub fine-grained PAT": `github_pat_${"a1B2c3D4_".repeat(4)}`,
12
+ "Anthropic key": `sk-ant-api03-${"Zy9-x8_W7".repeat(4)}`,
13
+ "OpenAI-style key": `sk-${"Qw3Er4Ty5U".repeat(3)}`,
14
+ "Slack token": "xoxb-1234567890-abcdefghij",
15
+ "AWS access key": "AKIAIOSFODNN7EXAMPLE",
16
+ "PEM private key": [
17
+ "-----BEGIN RSA PRIVATE KEY-----",
18
+ "MIIEowIBAAKCAQEAxyz",
19
+ "abcdef==",
20
+ "-----END RSA PRIVATE KEY-----",
21
+ ].join("\n"),
22
+ "Bearer token": "Bearer abcdefghijklmnop1234.-_",
23
+ JWT: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r",
24
+ };
25
+
26
+ describe("redactShapes", () => {
27
+ for (const [name, token] of Object.entries(PLANTED)) {
28
+ it(`redacts a ${name} and counts it`, () => {
29
+ const out = redactShapes(`before ${token} after`);
30
+ expect(out.text).not.toContain(token);
31
+ expect(out.text).toContain("before ");
32
+ expect(out.text).toContain(" after");
33
+ expect(out.count).toBe(1);
34
+ });
35
+ }
36
+
37
+ it("redacts the PEM body, not just the header", () => {
38
+ const out = redactShapes(PLANTED["PEM private key"] as string);
39
+ expect(out.text).not.toContain("MIIEowIBAAKCAQEAxyz");
40
+ expect(out.text).not.toContain("END RSA PRIVATE KEY");
41
+ });
42
+
43
+ it("counts every occurrence", () => {
44
+ const t = PLANTED["GitHub classic PAT"] as string;
45
+ const out = redactShapes(`${t} and ${t}\n${PLANTED["AWS access key"]}`);
46
+ expect(out.count).toBe(3);
47
+ expect(out.text).not.toContain(t);
48
+ });
49
+
50
+ it("does not flag a sha256 hex digest or ordinary prose", () => {
51
+ const sha = `${"a".repeat(8)}${"0123456789abcdef".repeat(3)}deadbeef`;
52
+ const text = `commit ${sha} skipped the task-store; sk-short is fine`;
53
+ const out = redactShapes(text);
54
+ expect(out.text).toBe(text);
55
+ expect(out.count).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe("redactValues", () => {
60
+ it("replaces live SECRET_ENV_VARS values and counts them", () => {
61
+ const env = { GH_TOKEN: "super-secret-gh-value", PATH: "/usr/bin" };
62
+ const out = redactValues(
63
+ "token=super-secret-gh-value again super-secret-gh-value /usr/bin",
64
+ env,
65
+ );
66
+ expect(out.text).not.toContain("super-secret-gh-value");
67
+ expect(out.text).toContain("/usr/bin");
68
+ expect(out.count).toBe(2);
69
+ });
70
+
71
+ it("is a no-op with no live secrets", () => {
72
+ expect(redactValues("hello", {})).toEqual({ text: "hello", count: 0 });
73
+ });
74
+ });
75
+
76
+ describe("redact", () => {
77
+ it("applies values then shapes and sums the counts", () => {
78
+ const env = { SLACK_BOT_TOKEN: "plain-live-value-123" };
79
+ const out = redact(
80
+ `a plain-live-value-123 b ${PLANTED["GitHub classic PAT"]}`,
81
+ env,
82
+ );
83
+ expect(out.count).toBe(2);
84
+ expect(out.text).not.toContain("plain-live-value-123");
85
+ expect(out.text).not.toContain("ghp_");
86
+ });
87
+ });
package/sentry.ts CHANGED
@@ -69,13 +69,15 @@ export interface ErrorCapturingClient {
69
69
  const MAX_SCRUB_DEPTH = 20;
70
70
 
71
71
  /** Recomputed per call (not cached at module load) so tests can mutate env freely between assertions. */
72
- function liveSecretValues(): string[] {
73
- return SECRET_ENV_VARS.map((key) => process.env[key]).filter(
72
+ export function liveSecretValues(
73
+ env: Readonly<Record<string, string | undefined>> = process.env,
74
+ ): string[] {
75
+ return SECRET_ENV_VARS.map((key) => env[key]).filter(
74
76
  (value): value is string => !!value,
75
77
  );
76
78
  }
77
79
 
78
- function redactSecrets<T>(
80
+ export function redactSecrets<T>(
79
81
  value: T,
80
82
  secrets: string[],
81
83
  seen: WeakSet<object>,