@intentius/chant 0.89.0 → 0.91.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/dist/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -5
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +55 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +29 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +12 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- package/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/handlers/serve.ts +2 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +23 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-tools.test.ts +211 -0
- package/src/cli/mcp/workspace-tools.ts +449 -0
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +36 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +18 -0
- package/src/workspace/records.ts +61 -3
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The box checks, WSP121 and WSP122 (#2726).
|
|
3
|
+
*
|
|
4
|
+
* A member whose entry has a `box` block is a box, or holds a box's
|
|
5
|
+
* declarations. A box holds no credential: each capability it needs outside
|
|
6
|
+
* itself (inference, Fountain, a third-party API) is reached through a
|
|
7
|
+
* broker, a runtime such as a lobby or a door, which holds the credential
|
|
8
|
+
* and enforces the scope the block declares. chant only states and checks
|
|
9
|
+
* this; it never runs a broker (ws-052).
|
|
10
|
+
*
|
|
11
|
+
* | Id | Code | Fails when |
|
|
12
|
+
* |---|---|---|
|
|
13
|
+
* | WSP121 | `box-credential-declared` | a file in the box member's directory carries a literal secret (fixed) |
|
|
14
|
+
* | WSP122 | `box-capability-unbrokered` | a capability in the block names no broker |
|
|
15
|
+
*
|
|
16
|
+
* WSP121 reads the member's files from the tree checked, so it works under
|
|
17
|
+
* `--at`, and runs no member code. A literal secret is either a value with a
|
|
18
|
+
* well-known credential shape (an AWS key id, a GitHub or Slack token, an
|
|
19
|
+
* `sk-` key, private key material), wherever it sits, or a literal string
|
|
20
|
+
* where a credential goes: under a key named like one (`API_KEY`, `token`,
|
|
21
|
+
* `credential`, `password`, ...), or the `value` of a `{ key, value }` vault
|
|
22
|
+
* secret. A reference is never one: `${VAR}` and `$VAR`, a secret-manager
|
|
23
|
+
* reference (`op://`, `bws://`, `infisical://`), a `{{...}}` template
|
|
24
|
+
* placeholder, and any expression, such as `process.env.X`, that is not a
|
|
25
|
+
* string literal. WSP121 is fixed: a credential in a box's files is in git.
|
|
26
|
+
*
|
|
27
|
+
* WSP123 and WSP124, box isolation (#2727), are in `./box-isolation.ts` and
|
|
28
|
+
* listed here after these two.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as ts from "typescript";
|
|
32
|
+
import type { WorkspaceCheck, WorkspaceCheckContext, WorkspaceDiagnostic } from "../checks";
|
|
33
|
+
import type { Member } from "../declaration";
|
|
34
|
+
import type { ReasonCode } from "../reason-codes";
|
|
35
|
+
import { joinPath, skippedDir, type WorkspaceTree } from "../tree";
|
|
36
|
+
import { BOX_ISOLATION_CHECKS } from "./box-isolation";
|
|
37
|
+
|
|
38
|
+
/** The read contract's codes for the box findings, carried as `code` on each. */
|
|
39
|
+
export const BOX_FINDING_CODES = ["box-credential-declared", "box-capability-unbrokered", "box-isolation-collision", "box-isolation-literal"] as const satisfies readonly ReasonCode[];
|
|
40
|
+
|
|
41
|
+
export const WSP_BOX_CREDENTIAL = "WSP121";
|
|
42
|
+
export const WSP_BOX_UNBROKERED = "WSP122";
|
|
43
|
+
|
|
44
|
+
/** Values whose shape alone says they are a credential. The list FTN001 uses, plus Anthropic keys. */
|
|
45
|
+
export const CREDENTIAL_SHAPES: readonly { pattern: RegExp; label: string }[] = [
|
|
46
|
+
{ pattern: /\bAKIA[0-9A-Z]{16}\b/, label: "an AWS access key id" },
|
|
47
|
+
{ pattern: /\b(?:ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}/, label: "a GitHub token" },
|
|
48
|
+
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/, label: "a GitHub fine-grained token" },
|
|
49
|
+
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/, label: "an Anthropic API key" },
|
|
50
|
+
{ pattern: /\bsk-[A-Za-z0-9_-]{20,}/, label: "a secret API key (sk-)" },
|
|
51
|
+
{ pattern: /\bftn_[A-Za-z0-9]{16,}/, label: "a Fountain API key" },
|
|
52
|
+
{ pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/, label: "a Slack token" },
|
|
53
|
+
{ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, label: "private key material" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** Secret-manager references a value may be instead of the secret. */
|
|
57
|
+
export const SECRET_REFERENCE_SCHEMES = ["op://", "bws://", "infisical://"] as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A key whose value is a credential: `API_KEY`, `GITHUB_TOKEN`, `apiKey`,
|
|
61
|
+
* `token`, `credential`, `password`, `AWS_SECRET_ACCESS_KEY`. A key that
|
|
62
|
+
* names where a secret is (`secretKey`, `TOKEN_FILE`, `tokenHash`) is not.
|
|
63
|
+
*/
|
|
64
|
+
const CREDENTIAL_KEY = /(?:^|[_-])(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|PRIVATE_?KEY|ACCESS_?KEY)$|^(?:apiKey|token|secret|password|credentials?|privateKey|accessKey|authToken)$|[a-z](?:ApiKey|Token|Secret|Password|Credentials?|PrivateKey|AccessKey)$/i;
|
|
65
|
+
|
|
66
|
+
/** Whether `key` names a credential. Exported for tests. */
|
|
67
|
+
export function isCredentialKey(key: string): boolean {
|
|
68
|
+
return CREDENTIAL_KEY.test(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether `value` refers to a secret rather than being one. Exported for tests. */
|
|
72
|
+
export function isSecretReference(value: string): boolean {
|
|
73
|
+
const v = value.trim();
|
|
74
|
+
if (v === "") return true;
|
|
75
|
+
if (v.includes("${")) return true;
|
|
76
|
+
// $VAR, $1, $(command): the shell fills it in.
|
|
77
|
+
if (v.startsWith("$")) return true;
|
|
78
|
+
if (/^\{\{[^}]*\}\}$/.test(v)) return true;
|
|
79
|
+
return SECRET_REFERENCE_SCHEMES.some((s) => v.startsWith(s));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a value under a credential key says where a secret is rather than
|
|
84
|
+
* being one: a path (`~/box/llm-token`, `/run/secrets/token`), a URL, or an
|
|
85
|
+
* environment variable's name (`GIT_TOKEN`).
|
|
86
|
+
*/
|
|
87
|
+
function namesWhereSecretIs(value: string): boolean {
|
|
88
|
+
const v = value.trim();
|
|
89
|
+
return /^(?:\/|~|\.\.?\/)/.test(v) || /^[a-z][a-z0-9+.-]*:\/\//i.test(v) || /^[A-Z][A-Z0-9_]*$/.test(v);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The credential shape `value` has, or undefined. */
|
|
93
|
+
export function credentialShape(value: string): string | undefined {
|
|
94
|
+
return CREDENTIAL_SHAPES.find((s) => s.pattern.test(value))?.label;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One literal secret found in a file. */
|
|
98
|
+
export interface LiteralSecret {
|
|
99
|
+
/** 1-based. */
|
|
100
|
+
line: number;
|
|
101
|
+
column: number;
|
|
102
|
+
/** What was found, for the message: never the value itself. */
|
|
103
|
+
what: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CODE_EXTENSIONS: Record<string, ts.ScriptKind> = {
|
|
107
|
+
".ts": ts.ScriptKind.TS,
|
|
108
|
+
".mts": ts.ScriptKind.TS,
|
|
109
|
+
".cts": ts.ScriptKind.TS,
|
|
110
|
+
".tsx": ts.ScriptKind.TSX,
|
|
111
|
+
".js": ts.ScriptKind.JS,
|
|
112
|
+
".mjs": ts.ScriptKind.JS,
|
|
113
|
+
".cjs": ts.ScriptKind.JS,
|
|
114
|
+
".jsx": ts.ScriptKind.JSX,
|
|
115
|
+
".json": ts.ScriptKind.JSON,
|
|
116
|
+
".jsonc": ts.ScriptKind.JSON,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const PROSE_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".html", ".rst"]);
|
|
120
|
+
|
|
121
|
+
/** Files larger than this are not read: a box's declarations are small, and a large file is data or a build. */
|
|
122
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
123
|
+
|
|
124
|
+
function propertyName(name: ts.PropertyName | undefined): string | undefined {
|
|
125
|
+
if (!name) return undefined;
|
|
126
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isStringLiteral = (n: ts.Node): n is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral => ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The literal secrets in a TypeScript, JavaScript or JSON file, read as
|
|
134
|
+
* syntax and never run.
|
|
135
|
+
*/
|
|
136
|
+
export function literalSecretsInCode(text: string, file: string, kind: ts.ScriptKind): LiteralSecret[] {
|
|
137
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
|
|
138
|
+
const out: LiteralSecret[] = [];
|
|
139
|
+
const at = (node: ts.Node, what: string) => {
|
|
140
|
+
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
141
|
+
out.push({ line: line + 1, column: character + 1, what });
|
|
142
|
+
};
|
|
143
|
+
const reported = new Set<ts.Node>();
|
|
144
|
+
const visit = (node: ts.Node) => {
|
|
145
|
+
if (isStringLiteral(node) && !reported.has(node)) {
|
|
146
|
+
const shape = credentialShape(node.text);
|
|
147
|
+
if (shape && !isSecretReference(node.text)) {
|
|
148
|
+
reported.add(node);
|
|
149
|
+
at(node, shape);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (ts.isPropertyAssignment(node) && isStringLiteral(node.initializer) && !reported.has(node.initializer)) {
|
|
153
|
+
const key = propertyName(node.name);
|
|
154
|
+
const value = node.initializer.text;
|
|
155
|
+
// A value with a credential's shape is reported by that shape, below.
|
|
156
|
+
if (key !== undefined && !isSecretReference(value) && !namesWhereSecretIs(value) && credentialShape(value) === undefined) {
|
|
157
|
+
if (isCredentialKey(key)) {
|
|
158
|
+
reported.add(node.initializer);
|
|
159
|
+
at(node.initializer, `a literal value for ${key}`);
|
|
160
|
+
} else if (key === "value" && ts.isObjectLiteralExpression(node.parent) && isVaultSecret(node.parent)) {
|
|
161
|
+
reported.add(node.initializer);
|
|
162
|
+
at(node.initializer, `a literal vault secret value for ${vaultSecretKey(node.parent) ?? "a key"}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
ts.forEachChild(node, visit);
|
|
167
|
+
};
|
|
168
|
+
visit(source);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `{ key, value }` with nothing else but a description: the shape of a vault secret. */
|
|
173
|
+
function isVaultSecret(obj: ts.ObjectLiteralExpression): boolean {
|
|
174
|
+
const names = obj.properties.map((p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) ? propertyName(p.name) : undefined));
|
|
175
|
+
return names.includes("key") && names.includes("value") && names.every((n) => n === "key" || n === "value" || n === "description");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function vaultSecretKey(obj: ts.ObjectLiteralExpression): string | undefined {
|
|
179
|
+
for (const p of obj.properties) {
|
|
180
|
+
if (ts.isPropertyAssignment(p) && propertyName(p.name) === "key" && isStringLiteral(p.initializer)) return p.initializer.text;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `KEY=value`, `export KEY=value` or `KEY: value`, the value optionally quoted. */
|
|
186
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*[=:]\s*(["']?)([^"'\s#]+)\2\s*(?:#.*)?$/;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The literal secrets in any other text file, such as a shell script, an
|
|
190
|
+
* env file or YAML: a credential shape anywhere, or a line assigning a
|
|
191
|
+
* literal to a credential-named key.
|
|
192
|
+
*/
|
|
193
|
+
export function literalSecretsInText(text: string, assignments = true): LiteralSecret[] {
|
|
194
|
+
const out: LiteralSecret[] = [];
|
|
195
|
+
text.split(/\r?\n/).forEach((lineText, i) => {
|
|
196
|
+
for (const { pattern, label } of CREDENTIAL_SHAPES) {
|
|
197
|
+
const m = pattern.exec(lineText);
|
|
198
|
+
if (m && !isSecretReference(m[0])) {
|
|
199
|
+
out.push({ line: i + 1, column: m.index + 1, what: label });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const a = assignments ? ASSIGNMENT.exec(lineText) : null;
|
|
204
|
+
if (a && isCredentialKey(a[1]) && !isSecretReference(a[3]) && !namesWhereSecretIs(a[3])) {
|
|
205
|
+
out.push({ line: i + 1, column: lineText.indexOf(a[3]) + 1, what: `a literal value for ${a[1]}` });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function extension(path: string): string {
|
|
212
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
213
|
+
const dot = base.lastIndexOf(".");
|
|
214
|
+
return dot > 0 ? base.slice(dot).toLowerCase() : "";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The literal secrets in one file, or none when the file is binary or too large. */
|
|
218
|
+
export function literalSecretsInFile(text: string, path: string): LiteralSecret[] {
|
|
219
|
+
if (text.length > MAX_FILE_BYTES || text.includes("\0")) return [];
|
|
220
|
+
const kind = CODE_EXTENSIONS[extension(path)];
|
|
221
|
+
if (kind !== undefined) return literalSecretsInCode(text, path, kind);
|
|
222
|
+
// Prose says "Token: ..." without meaning one, so only a credential's shape counts there.
|
|
223
|
+
return literalSecretsInText(text, !PROSE_EXTENSIONS.has(extension(path)));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Every file under `dir` in `tree`, skipping node_modules and dot-directories, sorted. */
|
|
227
|
+
function filesUnder(tree: WorkspaceTree, dir: string): string[] {
|
|
228
|
+
const out: string[] = [];
|
|
229
|
+
const walk = (d: string) => {
|
|
230
|
+
for (const e of tree.list(d) ?? []) {
|
|
231
|
+
const p = joinPath(d, e.name);
|
|
232
|
+
if (e.type === "dir") {
|
|
233
|
+
if (!skippedDir(e.name)) walk(p);
|
|
234
|
+
} else out.push(p);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
walk(dir);
|
|
238
|
+
return out.sort();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const boxMembers = (ctx: WorkspaceCheckContext): Member[] => ctx.declaration.members.filter((m) => m.box !== null);
|
|
242
|
+
|
|
243
|
+
export const BOX_CHECKS: readonly WorkspaceCheck[] = [
|
|
244
|
+
{
|
|
245
|
+
id: WSP_BOX_CREDENTIAL,
|
|
246
|
+
name: "box-credential-declared",
|
|
247
|
+
description:
|
|
248
|
+
"A box holds no credential: no file in a box member's directory carries a literal secret, in its declarations, an env or a vault's defaults. Variable references and secret-manager references (op://, bws://, infisical://) are not secrets.",
|
|
249
|
+
severity: "error",
|
|
250
|
+
configurable: false,
|
|
251
|
+
check(ctx) {
|
|
252
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
253
|
+
for (const m of boxMembers(ctx)) {
|
|
254
|
+
const dir = m.dir === "." ? "" : m.dir;
|
|
255
|
+
if (ctx.tree.stat(dir) !== "dir") continue;
|
|
256
|
+
for (const path of filesUnder(ctx.tree, dir)) {
|
|
257
|
+
let text: string;
|
|
258
|
+
try {
|
|
259
|
+
text = ctx.tree.read(path);
|
|
260
|
+
} catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const s of literalSecretsInFile(text, path)) {
|
|
264
|
+
out.push({
|
|
265
|
+
checkId: this.id,
|
|
266
|
+
severity: this.severity,
|
|
267
|
+
code: "box-credential-declared",
|
|
268
|
+
message: `box-credential-declared: ${path} holds ${s.what}, and member ${m.name} is a box, which holds no credential; replace it with a \${VAR} or secret-manager reference, or declare the capability as brokered in the member's box block`,
|
|
269
|
+
entity: m.name,
|
|
270
|
+
pointer: m.box!.pointer,
|
|
271
|
+
treeFile: path,
|
|
272
|
+
line: s.line,
|
|
273
|
+
column: s.column,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: WSP_BOX_UNBROKERED,
|
|
283
|
+
name: "box-capability-unbrokered",
|
|
284
|
+
description: "Every capability a box declares names the broker that holds its credential and enforces its scope.",
|
|
285
|
+
severity: "error",
|
|
286
|
+
configurable: true,
|
|
287
|
+
check(ctx) {
|
|
288
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
289
|
+
for (const m of boxMembers(ctx)) {
|
|
290
|
+
for (const c of m.box!.capabilities) {
|
|
291
|
+
if (c.broker !== null) continue;
|
|
292
|
+
out.push({
|
|
293
|
+
checkId: this.id,
|
|
294
|
+
severity: this.severity,
|
|
295
|
+
code: "box-capability-unbrokered",
|
|
296
|
+
message: `box-capability-unbrokered: member ${m.name}'s box needs ${c.name} and names no broker for it, so the box would hold its credential; set broker to the runtime that holds it, such as the lobby`,
|
|
297
|
+
entity: m.name,
|
|
298
|
+
pointer: c.pointer,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
// Box isolation (#2727), WSP123 and WSP124.
|
|
306
|
+
...BOX_ISOLATION_CHECKS,
|
|
307
|
+
];
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* | WSP113 | a pinned file that a superseding record pins at the old hash: the artifact did not follow the decision |
|
|
15
15
|
* | WSP114 | the records of `--kind` can't be read (fixed) |
|
|
16
16
|
* | WSP115 | a record kind the declaration names is missing or does not load as one (fixed, #2680) |
|
|
17
|
+
* | WSP116 | an answer kind's decision points file is missing or not valid (fixed, ws-058, #2738) |
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
|
|
@@ -99,4 +100,28 @@ export const RECORD_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
99
100
|
}));
|
|
100
101
|
},
|
|
101
102
|
},
|
|
103
|
+
{
|
|
104
|
+
id: "WSP116",
|
|
105
|
+
name: "decision-points-invalid",
|
|
106
|
+
description:
|
|
107
|
+
"Every answer kind the declaration names has a decision points file that matches decision-points.schema.json: each input names a read-contract output, each table row tests declared inputs and answers a candidate, each model id is pinned, and the chain ends in its one quorum (ws-058).",
|
|
108
|
+
severity: "error",
|
|
109
|
+
configurable: false,
|
|
110
|
+
check(ctx) {
|
|
111
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
112
|
+
for (const k of ctx.facts?.declaredKinds ?? []) {
|
|
113
|
+
if (!k.points) continue;
|
|
114
|
+
const where = k.declared.member === null ? "the workspace" : `member ${k.declared.member}`;
|
|
115
|
+
const finding = (message: string) => ({
|
|
116
|
+
checkId: this.id,
|
|
117
|
+
severity: this.severity,
|
|
118
|
+
message,
|
|
119
|
+
...(k.declared.member !== null ? { entity: k.declared.member } : {}),
|
|
120
|
+
pointer: `${k.declared.pointer}/kind`,
|
|
121
|
+
});
|
|
122
|
+
for (const p of k.points.problems) out.push(finding(`${where} declares the answer kind ${k.declared.kind}, whose points file ${k.points.file} ${p.field ? `has ${p.field}, which ${p.message}` : p.message}`));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
102
127
|
];
|
|
@@ -60,6 +60,13 @@ describe("the WSP catalog", () => {
|
|
|
60
60
|
"records-unreadable",
|
|
61
61
|
// A declared record kind that doesn't load breaks every reader's default (#2680).
|
|
62
62
|
"record-kind-unloadable",
|
|
63
|
+
// An answer kind's points file that isn't valid can't be asked (ws-058).
|
|
64
|
+
"decision-points-invalid",
|
|
65
|
+
// A credential in a box's files is in git (#2726).
|
|
66
|
+
"box-credential-declared",
|
|
67
|
+
// Two boxes sharing a value, or a box that only holds on one machine, is never the fix (#2727).
|
|
68
|
+
"box-isolation-collision",
|
|
69
|
+
"box-isolation-literal",
|
|
63
70
|
]);
|
|
64
71
|
});
|
|
65
72
|
|
package/src/workspace/checks.ts
CHANGED
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
* | WSP101 to WSP106 | generated files |
|
|
29
29
|
* | WSP111 to WSP114 | records read with `--kind` (#2549) |
|
|
30
30
|
* | WSP115 | the record kinds the declaration names (#2680) |
|
|
31
|
+
* | WSP116 | the decision points file of each declared answer kind (ws-058) |
|
|
32
|
+
* | WSP121, WSP122 | boxes: no literal credential, every capability brokered (#2726) |
|
|
33
|
+
* | WSP123, WSP124 | box isolation: no shared port, state path or cookie, no literal machine path (#2727) |
|
|
31
34
|
*/
|
|
32
35
|
|
|
33
36
|
import { realpathSync } from "node:fs";
|
|
@@ -45,6 +48,7 @@ import { gatherGeneratedFacts, GENERATED_CHECKS, type GeneratedFileFacts } from
|
|
|
45
48
|
import { gatherLedgerFacts, LEDGER_CHECKS, type MemberLedgerFacts } from "./checks/ledgers";
|
|
46
49
|
import { gatherPipelineFacts, PIPELINE_CHECKS, type MemberPipelineFacts } from "./checks/pipelines";
|
|
47
50
|
import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
|
|
51
|
+
import { BOX_CHECKS } from "./checks/boxes";
|
|
48
52
|
import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
|
|
49
53
|
|
|
50
54
|
/**
|
|
@@ -87,6 +91,12 @@ export interface WorkspaceDiagnostic extends PostSynthDiagnostic {
|
|
|
87
91
|
pointer: string;
|
|
88
92
|
/** An absolute path, for a finding about a file other than the declaration, such as a record (#2549). It is reported at its first line. */
|
|
89
93
|
file?: string;
|
|
94
|
+
/** A path in the tree checked, relative to the workspace root, for a finding about a file there (#2726). It is reported at `line` and `column`. */
|
|
95
|
+
treeFile?: string;
|
|
96
|
+
line?: number;
|
|
97
|
+
column?: number;
|
|
98
|
+
/** The read contract's code for the finding, when it has one (#2726). */
|
|
99
|
+
code?: ReasonCode;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
export interface WorkspaceCheck {
|
|
@@ -104,7 +114,7 @@ export interface WorkspaceCheck {
|
|
|
104
114
|
/** A diagnostic ready for lint's reporters, with the entry it is about. */
|
|
105
115
|
export interface WorkspaceFinding extends LintDiagnostic {
|
|
106
116
|
entity?: string;
|
|
107
|
-
/** The read contract's reason code
|
|
117
|
+
/** The read contract's reason code: for a WSP001 finding, why the declaration can't be read (#2536); for a box check, its finding code (#2726). */
|
|
108
118
|
code?: ReasonCode;
|
|
109
119
|
}
|
|
110
120
|
|
|
@@ -307,6 +317,8 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
307
317
|
...GENERATED_CHECKS,
|
|
308
318
|
// Records read with --kind (#2549).
|
|
309
319
|
...RECORD_CHECKS,
|
|
320
|
+
// Boxes and their brokered capabilities (#2726).
|
|
321
|
+
...BOX_CHECKS,
|
|
310
322
|
];
|
|
311
323
|
|
|
312
324
|
const BY_ID = new Map(WORKSPACE_CHECKS.map((c) => [c.id, c]));
|
|
@@ -459,11 +471,16 @@ export async function runDeclarationChecks(
|
|
|
459
471
|
const locate = (pointer: string): TextLocation => (parsed.ok ? parsed.locate(pointer) : { line: 1, column: 1 });
|
|
460
472
|
const file = display(declaration.file);
|
|
461
473
|
const toFinding = (d: WorkspaceDiagnostic): WorkspaceFinding => ({
|
|
462
|
-
...(d.
|
|
474
|
+
...(d.treeFile !== undefined
|
|
475
|
+
? { file: display(d.treeFile), line: d.line ?? 1, column: d.column ?? 1 }
|
|
476
|
+
: d.file !== undefined
|
|
477
|
+
? { file: display(relative(root, d.file).split(sep).join("/")), line: 1, column: 1 }
|
|
478
|
+
: { file, ...locate(d.pointer) }),
|
|
463
479
|
ruleId: d.checkId,
|
|
464
480
|
severity: d.severity,
|
|
465
481
|
message: d.message,
|
|
466
482
|
...(d.entity !== undefined ? { entity: d.entity } : {}),
|
|
483
|
+
...(d.code !== undefined ? { code: d.code } : {}),
|
|
467
484
|
});
|
|
468
485
|
const order = (a: LintDiagnostic, b: LintDiagnostic) => a.line - b.line || a.column - b.column || (a.ruleId < b.ruleId ? -1 : a.ruleId > b.ruleId ? 1 : 0);
|
|
469
486
|
const diagnostics = active.map(toFinding).sort(order);
|
|
@@ -29,4 +29,8 @@ export const recordKind = {
|
|
|
29
29
|
// Verdicts, and the field naming the decider, for each record's digest and
|
|
30
30
|
// quorum (#2671, #2672).
|
|
31
31
|
reviews: { field: "reviews", decider: "decided_by" },
|
|
32
|
+
// source is where a decision came from, and says where its proposal came
|
|
33
|
+
// from too: via, client, harness, model, session, turns and a transcript
|
|
34
|
+
// pinned by hash (#2708).
|
|
35
|
+
source: { field: "source" },
|
|
32
36
|
};
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"type": ["string", "null"]
|
|
51
51
|
},
|
|
52
52
|
"source": {
|
|
53
|
-
"description": "Where the decision was first recorded: a row of an issue's decisions table,
|
|
54
|
-
"oneOf": [{ "$ref": "#/definitions/sourceIssue" }, { "$ref": "#/definitions/sourceWorkspace" }]
|
|
53
|
+
"description": "Where the decision was first recorded: a row of an issue's decisions table, the workspace itself when no issue holds the decision (#2654), or a harness session when neither does (#2708). Each form may also say where the proposal came from: the fields via, client, harness, model, session, turns and transcript, which the kind opts in to with source: { field: \"source\" }.",
|
|
54
|
+
"oneOf": [{ "$ref": "#/definitions/sourceIssue" }, { "$ref": "#/definitions/sourceWorkspace" }, { "$ref": "#/definitions/sourceHarness" }]
|
|
55
55
|
},
|
|
56
56
|
"question": {
|
|
57
57
|
"description": "One sentence. Null only in an importer draft.",
|
|
@@ -319,7 +319,14 @@
|
|
|
319
319
|
"revision": {
|
|
320
320
|
"description": "The design revision that last changed this row, such as \"v8\". Null when the row carries no marker.",
|
|
321
321
|
"type": ["string", "null"]
|
|
322
|
-
}
|
|
322
|
+
},
|
|
323
|
+
"session": { "$ref": "#/definitions/sourceSession" },
|
|
324
|
+
"via": { "$ref": "#/definitions/sourceVia" },
|
|
325
|
+
"client": { "$ref": "#/definitions/sourceClient" },
|
|
326
|
+
"harness": { "$ref": "#/definitions/sourceHarnessId" },
|
|
327
|
+
"model": { "$ref": "#/definitions/sourceModel" },
|
|
328
|
+
"turns": { "$ref": "#/definitions/sourceTurns" },
|
|
329
|
+
"transcript": { "$ref": "#/definitions/sourceTranscript" }
|
|
323
330
|
}
|
|
324
331
|
},
|
|
325
332
|
"sourceWorkspace": {
|
|
@@ -335,16 +342,103 @@
|
|
|
335
342
|
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
|
|
336
343
|
},
|
|
337
344
|
"session": {
|
|
338
|
-
"description": "The id of the session the decision was made in, as the member names it. Null or absent when there was none.",
|
|
339
|
-
"
|
|
340
|
-
"minLength": 1
|
|
345
|
+
"description": "The id of the session the decision was made in, as the member names it, or the harness's session with the chant session record it was held in. Null or absent when there was none.",
|
|
346
|
+
"$ref": "#/definitions/sourceSession"
|
|
341
347
|
},
|
|
342
348
|
"issue": {
|
|
343
349
|
"description": "An issue the decision relates to, when there is one.",
|
|
344
350
|
"$ref": "#/definitions/issueRef"
|
|
351
|
+
},
|
|
352
|
+
"via": { "$ref": "#/definitions/sourceVia" },
|
|
353
|
+
"client": { "$ref": "#/definitions/sourceClient" },
|
|
354
|
+
"harness": { "$ref": "#/definitions/sourceHarnessId" },
|
|
355
|
+
"model": { "$ref": "#/definitions/sourceModel" },
|
|
356
|
+
"turns": { "$ref": "#/definitions/sourceTurns" },
|
|
357
|
+
"transcript": { "$ref": "#/definitions/sourceTranscript" }
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
"sourceHarness": {
|
|
361
|
+
"description": "A decision proposed in a harness session, with no issue row or workspace member behind it (#2708). It says how it arrived, in via, and may say the rest of where the proposal came from.",
|
|
362
|
+
"type": "object",
|
|
363
|
+
"additionalProperties": false,
|
|
364
|
+
"required": ["via"],
|
|
365
|
+
"properties": {
|
|
366
|
+
"issue": {
|
|
367
|
+
"description": "An issue the decision relates to, when there is one.",
|
|
368
|
+
"$ref": "#/definitions/issueRef"
|
|
369
|
+
},
|
|
370
|
+
"session": { "$ref": "#/definitions/sourceSession" },
|
|
371
|
+
"via": { "$ref": "#/definitions/sourceVia" },
|
|
372
|
+
"client": { "$ref": "#/definitions/sourceClient" },
|
|
373
|
+
"harness": { "$ref": "#/definitions/sourceHarnessId" },
|
|
374
|
+
"model": { "$ref": "#/definitions/sourceModel" },
|
|
375
|
+
"turns": { "$ref": "#/definitions/sourceTurns" },
|
|
376
|
+
"transcript": { "$ref": "#/definitions/sourceTranscript" }
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
"sourceVia": {
|
|
380
|
+
"description": "How the record reached the workspace: cli, a person or script at chant workspace records new; mcp, a harness through chant serve mcp, which fills this and client itself; harvest, a transcript read after the fact. A harvested decision is written proposed, and a person decides it. Data about the proposal, not trust.",
|
|
381
|
+
"enum": ["cli", "mcp", "harvest"]
|
|
382
|
+
},
|
|
383
|
+
"sourceClient": {
|
|
384
|
+
"description": "What wrote the record: the MCP client's clientInfo, or the CLI.",
|
|
385
|
+
"type": "object",
|
|
386
|
+
"additionalProperties": false,
|
|
387
|
+
"required": ["name"],
|
|
388
|
+
"properties": {
|
|
389
|
+
"name": { "type": "string", "minLength": 1 },
|
|
390
|
+
"version": { "type": "string", "minLength": 1 },
|
|
391
|
+
"title": { "type": "string", "minLength": 1 }
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
"sourceHarnessId": {
|
|
395
|
+
"description": "The harness the decision was proposed in, such as claude-code, codex, gemini-cli, opencode, fountain or hud.",
|
|
396
|
+
"type": "string",
|
|
397
|
+
"minLength": 1
|
|
398
|
+
},
|
|
399
|
+
"sourceModel": {
|
|
400
|
+
"description": "The model id the harness reports.",
|
|
401
|
+
"type": "string",
|
|
402
|
+
"minLength": 1
|
|
403
|
+
},
|
|
404
|
+
"sourceSession": {
|
|
405
|
+
"description": "The session the decision was made in: its id as a string, or an object with the harness's session or conversation id and the chant session record it was held in (#2697). Null when there was none.",
|
|
406
|
+
"oneOf": [
|
|
407
|
+
{ "type": "string", "minLength": 1 },
|
|
408
|
+
{ "type": "null" },
|
|
409
|
+
{
|
|
410
|
+
"type": "object",
|
|
411
|
+
"additionalProperties": false,
|
|
412
|
+
"minProperties": 1,
|
|
413
|
+
"properties": {
|
|
414
|
+
"id": { "description": "The harness's session or conversation id.", "type": "string", "minLength": 1 },
|
|
415
|
+
"record": { "description": "The id of the chant session record the decision was held in.", "type": "string", "minLength": 1 }
|
|
416
|
+
}
|
|
345
417
|
}
|
|
418
|
+
]
|
|
419
|
+
},
|
|
420
|
+
"sourceTurns": {
|
|
421
|
+
"description": "The turns of the session the decision was made in, from and to, counted as the harness counts them. to is not before from.",
|
|
422
|
+
"type": "object",
|
|
423
|
+
"additionalProperties": false,
|
|
424
|
+
"required": ["from", "to"],
|
|
425
|
+
"properties": {
|
|
426
|
+
"from": { "type": "integer", "minimum": 0 },
|
|
427
|
+
"to": { "type": "integer", "minimum": 0 }
|
|
346
428
|
}
|
|
347
429
|
},
|
|
430
|
+
"sourceTranscript": {
|
|
431
|
+
"description": "The session's transcript, pinned by a path or URI and the lowercase hex SHA-256 of its bytes, never copied into the record. A reader checks the transcript it holds is the one meant; chant workspace records warns source-transcript-drift when the file can be read here and hashes to something else. A path is absolute, starts ~/ from the home directory, or starts at the workspace root; of URIs, only file: ones are read.",
|
|
432
|
+
"type": "object",
|
|
433
|
+
"additionalProperties": false,
|
|
434
|
+
"required": ["sha256"],
|
|
435
|
+
"properties": {
|
|
436
|
+
"path": { "type": "string", "minLength": 1 },
|
|
437
|
+
"uri": { "type": "string", "minLength": 1 },
|
|
438
|
+
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
|
|
439
|
+
},
|
|
440
|
+
"oneOf": [{ "required": ["path"] }, { "required": ["uri"] }]
|
|
441
|
+
},
|
|
348
442
|
"issueRef": {
|
|
349
443
|
"type": "string",
|
|
350
444
|
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+$"
|